Laravel Cashier+Stripe订阅按比例退款逻辑及场景处理求助
Laravel Cashier + Stripe 订阅按比例退款的渠道处理疑问及代码实现
问题背景
我正在用Laravel和Laravel Cashier通过API实现订阅按比例退款功能,依托Stripe托管页面管理订阅,碰到了官方文档未覆盖的细节问题。
最终目标是订阅取消时自动为用户按比例退款,我将流程拆分为两部分:
- 扩展Stripe Webhook控制器,分发任务通过Cashier的
refund方法计算并发起退款; - 单独监听Stripe的
charge.refunded事件,仅在退款成功时调整用户的Stripe账户余额。
需要覆盖三类支付场景:
- A. 用户通过信用卡全额支付订阅费用;
- B. 用户完全使用账户余额支付;
- C. 用户通过信用卡支付,但部分金额被账户余额抵扣。
我希望避免退款异常或资金损失,认为需按原支付渠道分别退款,但对场景C的处理拿不准:是全额退至信用卡,还是拆分退至信用卡和账户余额?特此求助确认正确处理方式。
正在开发的退款任务代码
<?php namespace App\Jobs; use App\Mail\AmountProblemRefund; use App\Mail\ExceptionProcessingRefund; use App\Mail\InvoiceProblemRefund; use App\Mail\NoSubscriptionForRefund; use App\Mail\NotEnoughCreditBalanceForRefund; use App\Mail\NoUserForRefund; use App\Mail\RefundFailed; use Exception; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Laravel\Cashier\Subscription; use Stripe\Refund; class ProcessStripeRefundJob extends Job { /** * 创建新的任务实例 * * @return void */ public function __construct(public array $payload) { } public function run(): void { try { // 通过Stripe订阅ID查找应用内的订阅 $subscription = Subscription::where('stripe_id', $this->payload['data']['object']['id'])->first(); // 检查订阅是否不存在 if (!$subscription) { Mail::to('*****@gmail.com')->send(new NoSubscriptionForRefund($this->payload)); Log::error('未找到订阅: ' . $this->payload['data']['object']['id']); return; } $user = $subscription->user; // 检查用户是否不存在 if (!$user) { Mail::to('*****@gmail.com')->send(new NoUserForRefund($this->payload)); Log::error('订阅对应的用户未找到: ' . $subscription->id); return; } // 检查用户是否处于试用期 if ($user->onTrial()) { // 用户处于试用期,无需退款,直接返回成功 return; } // 获取订阅取消产生的按比例退款发票(即最新发票),订阅取消后该发票应已生成 $latestInvoice = $user->findInvoice($this->payload['data']['object']['latest_invoice']); // 获取包含原订阅金额的上一张发票 $previousInvoice = $user->findInvoice($latestInvoice->lines->data[0]->proration_details->credited_items->invoice); // 检查发票是否存在 if (!$latestInvoice || !$previousInvoice) { Mail::to('*****@gmail.com')->send(new InvoiceProblemRefund( $this->payload, // Stripe事件负载 $this->payload['data']['object']['latest_invoice'], // 最新发票ID )); Log::error('未找到最新发票或上一张发票'); return; } // 退款金额取自按比例退款发票的第一个订单项(系统仅提供一种订阅计划,用户仅能拥有一个订阅),单位为分 $refundAmount = abs($latestInvoice->lines->data[0]['amount']); // 检查退款金额是否有效 if ($refundAmount <= 0) { Mail::to('*****@gmail.com')->send(new AmountProblemRefund( $this->payload, // Stripe事件负载 $refundAmount )); Log::error('无效的退款金额: ' . $refundAmount); return; } // 获取用户账户余额(负数表示用户有可用余额,可用于支付后续订阅;此处取绝对值,单位为分) $balanceInPennies = abs($user->rawBalance()); // 判断支付是否来自账户余额 $paymentFromCreditBalance = false; //TODO - 实现此逻辑 $paymentFromOtherMethod = true; //TODO - 实现此逻辑 // 处理来自账户余额的支付退款 if ($paymentFromCreditBalance) { // TODO: 实现账户余额支付场景的退款流程 } // 处理其他支付方式的退款(此处指信用卡支付) if ($paymentFromOtherMethod) { // 检查用户余额是否足以覆盖退款金额 if ($balanceInPennies >= $refundAmount) { // 在Stripe中创建退款 $refund = $user->refund($previousInvoice->payment_intent, [ 'amount' => $refundAmount, 'reason' => Refund::REASON_REQUESTED_BY_CUSTOMER, 'metadata' => [ 'subscription_id' => $subscription->id, 'user_id' => $user->id, 'message' => '订阅取消按比例退款 - 处理完成后调整账户余额' ], ]); // 检查退款是否成功 if (!$refund) { Log::error('用户退款失败: ' . $user->id); Mail::to('*****@gmail.com')->send(new RefundFailed( $this->payload, json_encode($refund) )); } } else { // 发送退款失败邮件通知 Mail::to('*****@gmail.com')->send(new NotEnoughCreditBalanceForRefund($this->payload)); } } } catch (Exception $exception) { // 记录错误(防止邮件发送失败时无备份) Log::error('退款处理出错', [ 'payload' => $this->payload, 'line' => $exception->getLine(), 'file' => $exception->getFile(), 'stack' => $exception->getTraceAsString(), 'exception' => $exception->getMessage(), ]); // 发送异常通知邮件 Mail::to('*****@gmail.com')->send(new ExceptionProcessingRefund( $this->payload, $exception->getLine(), $exception->getFile(), $exception->getMessage() )); } } }
场景C的正确处理方式
根据Stripe的退款规则和Cashier的设计逻辑,必须按原支付渠道拆分退款:
- 原订单中用账户余额抵扣的部分,退款时退回到用户的Stripe账户余额;
- 原订单中信用卡实际支付的部分,退款时退回到用户的信用卡。
这样处理的原因:
- 符合资金流向合规要求,避免用户资金混淆;
- Stripe退款API默认优先按原支付方式拆分退款,强制全额退信用卡可能触发风控导致失败;
- 保持用户账户余额准确性,避免后续订阅支付时出现计算错误。
代码优化补充
针对现有Job代码,补充关键逻辑:
1. 原支付渠道拆分比例计算
从原发票中提取支付拆分详情:
// 获取原发票的支付意图详情 $paymentIntent = $previousInvoice->paymentIntent; // 计算原订单中账户余额抵扣的金额(单位:分) $creditApplied = $previousInvoice->total - ($paymentIntent?->amount_received ?? 0); // 按比例计算本次退款中各渠道的金额 $refundToBalance = round(($creditApplied / $previousInvoice->total) * $refundAmount); $refundToCard = $refundAmount - $refundToBalance;
2. 场景B(全额余额支付)的退款逻辑
if ($paymentFromCreditBalance) { // Cashier中rawBalance负数为可用余额,增加余额需传入负数(即减少用户的负债) $user->updateBalance(-$refundAmount); Log::info('账户余额退款完成: 用户ID=' . $user->id . ', 金额=' . $refundAmount); }
3. 场景C(混合支付)的拆分退款逻辑
// 处理混合支付场景 if ($refundToBalance > 0 && $refundToCard > 0) { // 先处理信用卡退款 if ($refundToCard > 0 && $previousInvoice->payment_intent) { $user->refund($previousInvoice->payment_intent, [ 'amount' => $refundToCard, 'reason' => Refund::REASON_REQUESTED_BY_CUSTOMER, 'metadata' => [ 'subscription_id' => $subscription->id, 'user_id' => $user->id, 'type' => 'card_refund' ], ]); } // 再处理余额退款 if ($refundToBalance > 0) { $user->updateBalance(-$refundToBalance); } Log::info('混合退款完成: 用户ID=' . $user->id . ', 退信用卡=' . $refundToCard . ', 退余额=' . $refundToBalance); }
4. 完善支付渠道判断逻辑
// 判断是否为全额余额支付(无支付意图且发票金额大于0) $paymentFromCreditBalance = is_null($previousInvoice->payment_intent) && $previousInvoice->total > 0; // 判断是否为混合支付或纯信用卡支付 $paymentFromOtherMethod = !$paymentFromCreditBalance;
内容的提问来源于stack exchange,提问作者Robert
相关产品推荐
相关产品推荐

