You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Laravel Cashier+Stripe订阅按比例退款逻辑及场景处理求助

Laravel Cashier + Stripe 订阅按比例退款的渠道处理疑问及代码实现

问题背景

我正在用Laravel和Laravel Cashier通过API实现订阅按比例退款功能,依托Stripe托管页面管理订阅,碰到了官方文档未覆盖的细节问题。

最终目标是订阅取消时自动为用户按比例退款,我将流程拆分为两部分:

  1. 扩展Stripe Webhook控制器,分发任务通过Cashier的refund方法计算并发起退款;
  2. 单独监听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账户余额;
  • 原订单中信用卡实际支付的部分,退款时退回到用户的信用卡。

这样处理的原因:

  1. 符合资金流向合规要求,避免用户资金混淆;
  2. Stripe退款API默认优先按原支付方式拆分退款,强制全额退信用卡可能触发风控导致失败;
  3. 保持用户账户余额准确性,避免后续订阅支付时出现计算错误。

代码优化补充

针对现有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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.25 03:25:09