基于 Laravel Cashier 提供订阅支付解决方案


laravel cashier

简介

Laravel Cashier 为通过 Stripe 实现订阅支付服务提供了一个优雅的流式接口。它封装了几乎所有你恐惧编写的样板化的订阅支付代码。除了基本的订阅管理外,Cashier 还支持处理优惠券、订阅升级/替换、订阅「数量」、取消宽限期,甚至生成 PDF 发票。

升级 Cashier

要升级到最新版本的 Cashier,需要仔细阅读升级指南

注:为了防止破坏性修改,Cashier 使用固定版本的 Stripe API,比如 Cashier 12 使用的 Stripe API 版本是 2020-03-02。Stripe API 版本会在次要版本中更新以便可以使用新的 Stripe 特性和优化。

安装

首先,通过 Composer 安装用于 Stripe 的 Cashier 扩展包:

composer require laravel/cashier

注:为确保 Cashier 正确处理了所有 Stripe 事件,需要设置 Cashier 的 webhook 处理

数据库迁移

Cashier 服务提供者会注册自己的数据库迁移目录,所以安装完扩展包后记得运行数据库迁移。Cashier 迁移将会添加多个字段到 users 表,同时创建新的 subscriptions 表来存放所有顾客的订阅记录:

php artisan migrate

如果你需要覆盖 Cashier 扩展包自带的数据库迁移,可以使用 Artisan 命令 vendor:publish 发布这些迁移文件:

php artisan vendor:publish --tag="cashier-migrations"

如果你想要阻止 Cashier 迁移的完整运行,可以使用 Cashier 提供的 ignoreMigrations 方法。通常,该方法应该在 AppServiceProviderregister 方法中调用:

use Laravel\Cashier\Cashier;

Cashier::ignoreMigrations();

注:Stripe 建议任何用于存储 Stripe 标识符的字段都应该是大小写敏感的,因此,你需要确保将 stripe_id 字段被设置为 utf8_bin(MySQL),更多信息可以参考 Stripe 官方文档

配置

账单模型

接下来,添加 Billable Trait 到模型定义,这个 Trait 提供了多个方法以便执行常用支付任务,例如创建订阅、使用优惠券以及更新信用卡信息:

use Laravel\Cashier\Billable;
    
class User extends Authenticatable
{
    use Billable;
}

Cashier 默认假设你的 Billable 模型是 Laravel 自带的 App\Models\User 类。如果你想要修改该约定,可以在 .env 文件中指定其它模型:

CASHIER_MODEL=App\Models\User

注:如果你使用的不是 Laravel 自带的 App\Models\User 模型,则需要发布并修改默认的Cashier 迁移文件以匹配你使用模型对应的表名。

API 密钥

接下来,你需要在 .env 文件中配置 Stripe 密钥,你可以从 Stripe 后台控制面板中获取这些 Stripe API 密钥:

STRIPE_KEY=your-stripe-key
STRIPE_SECRET=your-stripe-secret

货币配置

Cashier 默认货币是美元(USD),你可以通过设置 CASHIER_CURRENCY 环境变量来修改默认的货币:

CASHIER_CURRENCY=rmb

除了配置 Cashier 的货币之外,你还可以在格式化用于显示在发票上的金额时指定本地化配置。在底层,Cashier 使用了 PHP 的 NumberFormatter 类 来设置本地货币:

CASHIER_CURRENCY_LOCALE=nl_BE

注:为了使用本地化配置而不是 en,需要确保安装了 PHP ext-intl 扩展并在服务器上配置启用。

日志

Cashier 允许你指定日志通道来记录所有与 Stripe 相关的异常,你可以通过环境变量 CASHIER_LOGGER 来指定:

CASHIER_LOGGER=stack

顾客

获取顾客

你可以使用 Cashier::findBillable 方法通过 Stripe ID 获取顾客信息。该方法返回的是一个 Billable 模型实例:

use Laravel\Cashier\Cashier;

$user = Cashier::findBillable($stripeId);

创建顾客

有时候,你可能希望在不开始订阅的情况下创建一个 Stripe 顾客。这可以通过 createAsStripeCustomer 方法来实现:

$stripeCustomer = $user->createAsStripeCustomer();

顾客在 Stripe 中创建后,你可以过一段时间再开始订阅。你还可以使用可选的 $options 数组传入所有 Stripe API 支持的额外参数:

$stripeCustomer = $user->createAsStripeCustomer($options);

如果你想要返回顾客对象,可以使用 asStripeCustomer 方法:

$stripeCustomer = $user->asStripeCustomer();

此外,如果已经是 Stripe 用户,则直接返回对应的顾客对象,否则重新创建,这种场景可以使用 createOrGetStripeCustomer 方法:

$stripeCustomer = $user->createOrGetStripeCustomer();

更新顾客

有时候,你可能想要使用额外的信息直接更新 Stripe 顾客信息,这可以通过 updateStripeCustomer 方法来完成:

$stripeCustomer = $user->updateStripeCustomer($options);

账单入口

Stripe 提供了一个简单的方式来设置账单入口以便用户可以管理订阅、支付方法、以及查看历史账单。你可以在控制器或路由中使用 redirectToBillingPortal 方法将用户重定向到账单入口:

use Illuminate\Http\Request;

public function billingPortal(Request $request)
{
    return $request->user()->redirectToBillingPortal();
}

默认情况下,当用户完成对订阅的管理后,会返回到应用的 home 路由,你可以通过传递 URL 作为 redirectToBillingPortal 方法的参数来自定义用户返回的 URL:

use Illuminate\Http\Request;

public function billingPortal(Request $request)
{
    return $request->user()->redirectToBillingPortal(
        route('billing')
    );
}

如果你只想要生成账单入口的 URL,可以使用 billingPortalUrl 方法:

$url = $user->billingPortalUrl(route('billing'));

支付方法

存储支付方法

为了使用 Stripe 创建订阅或者进行「一次性」支付,你需要存储支付方法并从 Stripe 中获取对应的标识符。这种方式可用于实现你是否计划使用这个支付方法进行订阅还是单次收费,下面我们分别来介绍这两种方法。

用于订阅的支付方法

当我们为顾客存储信用卡以便将来使用时,一定要使用 Stripe Setup Intents API 来安全地收集顾客的支付方法细节。「Setup Intents」用于告知 Stripe 使用顾客的支付方法进行收费的意图。Cashier 的 Billable Trait 包含了 createSetupIntent 方法来创建新的「Setup Intent」,你需要在渲染收集顾客支付方法细节表单的路由或控制器方法中调用这个方法:

return view('update-payment-method', [
    'intent' => $user->createSetupIntent()
]);

创建完 Setup Intent 并将其传递给视图后,你需要在收集支付方法的元素中添加它的 secret。例如,参考下面这个「更新支付方法」表单:

<input id="card-holder-name" type="text">
    
<!-- Stripe Elements Placeholder -->
<div id="card-element"></div>
    
<button id="card-button" data-secret="{{ $intent->client_secret }}">
    Update Payment Method
</button>

接下来,会通过 Stripe.js 库添加一个 Stripe 元素到表单,并安全地收集顾客的支付细节:

<script src="https://js.stripe.com/v3/"></script>
    
<script>
    const stripe = Stripe('stripe-public-key');
    
    const elements = stripe.elements();
    const cardElement = elements.create('card');
    
    cardElement.mount('#card-element');
</script>

接下来,使用 Stripe 的 handleCardSetup 方法验证信用卡并从 Stripe 获取一个安全的「支付方法标识符」:

const cardHolderName = document.getElementById('card-holder-name');
const cardButton = document.getElementById('card-button');
const clientSecret = cardButton.dataset.secret;
    
cardButton.addEventListener('click', async (e) => {
    const { setupIntent, error } = await stripe.handleCardSetup(
        clientSecret, cardElement, {
            payment_method_data: {
                billing_details: { name: cardHolderName.value }
            }
        }
    );
    
    if (error) {
        // Display "error.message" to the user...
    } else {
        // The card has been verified successfully...
    }
});

信用卡被 Stripe 验证后,就可以传递返回的 setupIntent.payment_method 标识符给 Laravel 应用,将它和特定用户关联起来。该支付方法既可以以新的支付方法添加,也可以用于更新默认的支付方法。你还可以立即使用这个支付方法标识符来创建一个新的订阅

注:如果你想要了解更多关于 Setup Intents 以及获取顾客支付细节的信息,可以参考 Stripe 官方文档

用于单次付费的支付方法

当然,如果顾客支付方法使用的是单次付费,我们只需要使用支付方法标识符一次即可。由于 Stripe 本身的限制,你不可以使用存储的默认顾客支付方法进行单次付费,必须允许顾客通过 Stripe.js 库进入他们的支付方法细节。例如,参考下面这个表单:

<input id="card-holder-name" type="text">
    
<!-- Stripe Elements Placeholder -->
<div id="card-element"></div>
    
<button id="card-button">
    Process Payment
</button>

接下来,通过 Stripe.js 库添加 Stripe 元素到这个表单,并安全地收集顾客的支付细节:

<script src="https://js.stripe.com/v3/"></script>
    
<script>
    const stripe = Stripe('stripe-public-key');
    
    const elements = stripe.elements();
    const cardElement = elements.create('card');
    
    cardElement.mount('#card-element');
</script>

接下来,使用 Stripe 的 createPaymentMethod 方法验证信用卡并获取一个安全的「支付方法标识符」:

const cardHolderName = document.getElementById('card-holder-name');
const cardButton = document.getElementById('card-button');
    
cardButton.addEventListener('click', async (e) => {
    const { paymentMethod, error } = await stripe.createPaymentMethod(
        'card', cardElement, {
            billing_details: { name: cardHolderName.value }
        }
    );
    
    if (error) {
        // Display "error.message" to the user...
    } else {
        // The card has been verified successfully...
    }
});

如果信用卡验证成功,就可以传递 paymentMethod.id 到你的 Laravel 应用并处理一次性付费

获取支付方法

Billable 模型实例上的 paymentMethods 方法会返回 Laravel\Cashier\PaymentMethod 实例集合:

$paymentMethods = $user->paymentMethods();

要获取默认的支付方法,可以使用 defaultPaymentMethod 方法:

$paymentMethod = $user->defaultPaymentMethod();

还可以使用 findPaymentMethod 方法通过 Billable 模型获取指定支付方法:

$paymentMethod = $user->findPaymentMethod($paymentMethodId);

判断用户是否拥有支付方法

要判断某个 Billable 模型对应账户是否有默认的支付方法,可以使用 hasDefaultPaymentMethod 方法:

if ($user->hasDefaultPaymentMethod()) {
    //
}

要判断某个 Billable 模型对应账户是否有至少一个支付方法,可以使用 hasPaymentMethod 方法:

if ($user->hasPaymentMethod()) {
    //
}

更新默认支付方法

updateDefaultPaymentMethod 方法可用于更新顾客的默认支付方法信息,该方法接收一个 Stripe 支付方法标识符并将新的支付方法分配为默认的支付方法:

$user->updateDefaultPaymentMethod($paymentMethod);

为了同步应用的默认支付方法信息到 Stripe 顾客的默认支付方法信息,可以使用 updateDefaultPaymentMethodFromStripe 方法:

$user->updateDefaultPaymentMethodFromStripe();

注:顾客的默认支付方法只能用于发票和创建新的订阅,由于 Stripe 的限制,不能将其用于单次付费。

添加支付方法

要添加新的支付方法,可以调用 Billable 用户的 addPaymentMethod 方法,并传递支付方法标识符:

$user->addPaymentMethod($paymentMethod);

注:要了解如何获取支付方法标识符,请参考支付方法存储文档

删除支付方法

要删除一个支付方法,你可以调用要删除的 Laravel\Cashier\PaymentMethod 实例上的 delete 方法:

$paymentMethod->delete();

deletePaymentMethods 方法将会删除指定 Billable 模型上的所有支付方法信息:

$user->deletePaymentMethods();

注:如果某个用户拥有一个有效的订阅,你需要避免删除默认的支付方法。

订阅

创建订阅

要创建一个订阅,首先要获取一个账单模型的实例,通常是 App\Models\User 的实例。获取到该模型实例之后,你可以使用 newSubscription 方法来创建该模型的订阅:

$user = User::find(1);
    
$user->newSubscription('default', 'premium')->create($token);

第一个传递给 newSubscription 方法的参数是该订阅的名字,如果应用只有一个订阅,可以将其称作 defaultprimary,第二个参数用于指定用户订阅的计划,该值对应 Stripe 中相应计划的标识符。

接收 Stripe 支付方法标识符或者 Stripe PaymentMethod 对象的 create 方法会自动创建这个 Stripe 订阅,同时更新数据库中 Stripe 的顾客 ID(即 users 表中的 stripe_id)和其它相关的账单信息。

注:直接传递支付方法标识符到 create() 订阅方法还会自动将其添加到用户存储的支付方法中。

数量

如果你想要在创建订阅时设置计划的数量,可以使用 quantity 方法:

$user->newSubscription('default', 'price_monthly')
     ->quantity(5)
     ->create($paymentMethod);

其它细节

如果你想要指定其它客户或者订阅细节,你可以将其作为第二个参数传递给 create 方法:

$user->newSubscription('default', 'monthly')->create($paymentMethod, [
    'email' => $email,
]);

要了解更多 Stripe 支持的字段,可以查看 Stripe 关于创建顾客创建订阅的文档。

优惠券

如果你想要在创建订阅的时候使用优惠券,可以使用 withCoupon 方法:

$user->newSubscription('default', 'monthly')
     ->withCoupon('code')
     ->create($paymentMethod);

添加订阅

如果你想要为已经有默认支付方式的顾客添加订阅,可以在使用 newSubscription 方法时使用 add 方法:

$user = User::find(1);

$user->newSubscription('default', 'price_premium')->add();

检查订阅状态

用户订阅你的应用后,你可以使用各种便利的方法来简单检查订阅状态。首先,如果用户有一个有效的订阅,则 subscribed 方法返回 true,即使订阅现在处于试用期:

if ($user->subscribed('default')) {
    //
}

subscribed 方法还可以用于路由中间件,基于用户订阅状态允许你对路由和控制器的访问进行过滤:

public function handle($request, Closure $next){
    if ($request->user() && ! $request->user()->subscribed('default')) {
        // This user is not a paying customer...
        return redirect('billing');
    }
    
    return $next($request);
}

如果你想要判断一个用户是否还在试用期,可以使用 onTrial 方法,该方法对于还处于试用期的用户显示警告信息很有用:

if ($user->->subscription('default')->onTrial()) {
    //
}

subscribedToPlan 方法可用于判断用户是否基于 Stripe ID 订阅了给定的计划,在本例中,我们会判断用户的 default 订阅是否订阅了 monthly 计划:

if ($user->subscribedToPlan('monthly', 'default')) {
    //
}

传递数组到 subscribedToPlan 方法可以判断用户的 default 订阅是否在 monthly 或者 yearly 计划中有效:

if ($user->subscribedToPlan(['monthly', 'yearly'], 'default')) {
    //
}

recurring 方法可用于判定用户当前是否已经订阅并且不在试用期:

if ($user->subscription('default')->recurring()) {
    //
}

已取消的订阅状态

要判断用户是否曾经是有效的订阅者,但现在取消了订阅,可以使用 cancelled 方法:

if ($user->subscription('default')->cancelled()) {
    //
}

你还可以判断用户是否曾经取消过订阅,但现在仍然在「宽限期」直到完全失效。例如,如果一个用户在3月5号取消了一个实际有效期到3月10号的订阅,该用户处于「宽限期」直到3月10号。注意 subscribed 方法在此期间仍然返回 true

if ($user->subscription('default')->onGracePeriod()) {
    //
}

要判断用户已经取消订阅并且不在「宽限期」内,可以使用 ended 方法:

if ($user->subscription('default')->ended()) {
    //
}

订阅作用域

大多数订阅状态还可以用作查询作用域,因此,你可以通过数据库查询轻松获取给定状态的订阅:

// 获取所有有效的订阅...
$subscriptions = Subscription::query()->active()->get();

// 获取某个用户所有已取消的订阅...
$subscriptions = $user->subscriptions()->cancelled()->get();

所有内置的订阅查询作用域列表如下:

Subscription::query()->active();
Subscription::query()->cancelled();
Subscription::query()->ended();
Subscription::query()->incomplete();
Subscription::query()->notCancelled();
Subscription::query()->notOnGracePeriod();
Subscription::query()->notOnTrial();
Subscription::query()->onGracePeriod();
Subscription::query()->onTrial();
Subscription::query()->pastDue();
Subscription::query()->recurring();

未完成和过期状态

如果某个订阅要求创建完订阅后进行二次付款操作,将被标记为 incomplete。订阅状态被存储在 Cashier subscriptions 数据表的 stripe_status 字段。

类似的,如果在切换订阅计划时也需要进行二次付款操作,对应的订阅会被标记为 past_due。当你的订阅处于这种状态时,只有等到顾客确认支付后它们才会被激活。我们可以使用 Billable 模型或者订阅实例的 hasIncompletePayment 方法来检查某个订阅是否存在未完成支付:

if ($user->hasIncompletePayment('default')) {
    //
}
    
if ($user->subscription('default')->hasIncompletePayment()) {
    //
}

如果某个订阅存在未完成支付,你需要引导用户到 Cashier 的支付确认页面,并传递 latestPayment 标识符。你可以使用订阅实例上的 latestPayment 方法来获取这个标识符:

<a href="{{ route('cashier.payment', $subscription->latestPayment()->id) }}">
    Please confirm your payment.
</a>

如果你想要订阅在 past_due 状态下依然有效,可以使用 Cashier 提供的 keepPastDueSubscriptionsActive 方法,通常,该方法需要在 AppServiceProviderboot 方法中调用:

use Laravel\Cashier\Cashier;

/**
 * Register any application services.
 *
 * @return void
 */
public function register()
{
    Cashier::keepPastDueSubscriptionsActive();
}

注:当某个订阅处于 incomplete 状态,只有等到支付被确认后才能进行修改。因此,当订阅处于 incomplete 状态时,执行 swapupdateQuantity 方法会抛出异常。

修改计划

用户订阅应用后,偶尔想要改变到新的订阅计划,要将用户切换到新的订阅,传递计划标识符到 swap 方法:

$user = App\Models\User::find(1);
$user->subscription('default')->swap('provider-plan-id');

如果用户在试用,试用期将会被维护。还有,如果订阅存在多个,数量也可以被维护。

如果你想要切换计划并取消用户所在的所有试用期,你可以使用 skipTrial 方法:

$user->subscription('default')
     ->skipTrial()
     ->swap('provider-plan-id');

如果你想要切换计划并立即为用户开具发票,而不是等到下一个结算周期,可以使用 swapAndInvoice 方法:

$user = App\Models\User::find(1);
    
$user->subscription('default')->swapAndInvoice('provider-plan-id');

按比例比例

默认情况下,Stripe 会在订阅计划间切换时按比例进行分配,noProrate 可用于在修改订阅计划时不使用按比例分配机制:

$user->subscription('default')->noProrate()->swap('provider-plan-id');

更多关于订阅计划按比例分配的细节,请参考 Stripe 官方文档

注:在 swapAndInvoice 方法之前执行 noProrate 方法不会影响按比例分配。发票始终都会开具。

订阅数量

有时候订阅也会被数量影响,例如,应用中每个账户每月需要付费$10,要简单增加或减少订阅数量,使用 incrementQuantitydecrementQuantity 方法:

$user = User::find(1);
    
$user->subscription('default')->incrementQuantity();
    
// Add five to the subscription's current quantity...
$user->subscription('default')->incrementQuantity(5);
    
$user->subscription('default')->decrementQuantity();
    
// Subtract five to the subscription's current quantity...
$user->subscription('default')->decrementQuantity(5);

你也可以使用 updateQuantity 方法指定数量:

$user->subscription('default')->updateQuantity(10);

noProrate 方法可用于更新订阅数量而无需对收费进行评级:

$user->subscription('default')->noProrate()->updateQuantity(10);

想要了解更多订阅数量信息,查阅相关Stripe 官方文档

注:处理多计划订阅时,上述数量方法需要一个额外的「计划」参数。

多方案订阅计划

通过多方案订阅计划你可以分配多个付费方案给单个订阅计划。例如,假设你正在构建一个顾客咨询服务应用,其中包含了一个基础的¥10/月的订阅方案,但是如果提供附加的实时聊天服务,需要支付¥15/月:

$user = User::find(1);

$user->newSubscription('default', [
    'price_monthly',
    'chat-plan',
])->create($paymentMethod);

现在顾客在 default 订阅计划上就有了两个付费方案,两个方案都会按照各自的付费周期收费。你还可以使用 quantity 方法指定每个方案的数量:

$user = User::find(1);

$user->newSubscription('default', ['price_monthly', 'chat-plan'])
    ->quantity(5, 'chat-plan')
    ->create($paymentMethod);

或者,你可以使用 plan 方法动态添加额外的方案和数量:

$user = User::find(1);

$user->newSubscription('default', 'price_monthly')
    ->plan('chat-plan', 5)
    ->create($paymentMethod);

还可以后续添加新方案到已存在的订阅计划:

$user = User::find(1);

$user->subscription('default')->addPlan('chat-plan');

上面的示例代码会添加新方案并且顾客会在下个支付周期为其支付。如果你想要让顾客立即支付,可以使用 addPlanAndInvoice 方法:

$user->subscription('default')->addPlanAndInvoice('chat-plan');

如果你想要添加指定数量的方案,可以将数量值作为第二个参数传递给 addPlan 或者 addPlanAndInvoice 方法:

$user = User::find(1);

$user->subscription('default')->addPlan('chat-plan', 5);

你可以使用 removePlan 方法从订阅计划中移除这些方案:

$user->subscription('default')->removePlan('chat-plan');

注:不能移除订阅计划中最后一个方案(至少需要保留一个),取而代之地,需要通过取消订阅的方式移除该方案。

多方案订阅切换

你还可以在多方案订阅计划中切换不同的方案。例如,假设你正在订阅 basic-plan 服务中的 chat-plan 方案,然后想要升级到 pro-plan 方案,可以这么做:

$user = User::find(1);

$user->subscription('default')->swap(['pro-plan', 'chat-plan']);

执行上述代码时,底层的 basic-plan 订阅项会被删除,而 chat-plan 订阅项会保留下来,新的订阅项 pro-plan 则会被创建。

你还可以指定订阅项选项,例如,你可能需要指定订阅方案的数量:

$user = User::find(1);

$user->subscription('default')->swap([
    'pro-plan' => ['quantity' => 5],
    'chat-plan'
]);

如果你想要切换订阅计划中的单个方案,可以使用订阅项本身的 swap 方法来实现。这种方式在你想要保留所有已存在订阅项元数据时很有用:

$user = User::find(1);

$user->subscription('default')
        ->findItemOrFail('basic-plan')
        ->swap('pro-plan');

按比例分配

默认情况下,添加或移除订阅计划中的付费方案时,Stripe 会按比例分配支付金额。如果你不想要按比例调整,可以在方案操作上使用 noProrate 方法:

$user->subscription('default')->noProrate()->removePlan('chat-plan');

数量

如果你想要更新单个订阅方案的数量,可以使用已存在的数量方法并传入计划名作为额外参数到该方法:

$user = User::find(1);

$user->subscription('default')->incrementQuantity(5, 'chat-plan');

$user->subscription('default')->decrementQuantity(3, 'chat-plan');

$user->subscription('default')->updateQuantity(10, 'chat-plan');

注:当你在订阅计划中设置了多个方案,Subscription 模型的 stripe_planquantity 属性值会变成 null。要访问独立的订阅方案,可以使用 Subscription 模型类中的 items 方法。

订阅项

当一个订阅计划有多个方案时,就会有对应的多个订阅项存储在数据表 subscription_items 中,你可以通过订阅计划上的 items 方法访问它们:

$user = User::find(1);

$subscriptionItem = $user->subscription('default')->items->first();

// 获取指定订阅项的订阅计划和数量...
$stripePlan = $subscriptionItem->stripe_plan;
$quantity = $subscriptionItem->quantity;

还可以使用 findItemOrFail 方法获取指定方案:

$user = User::find(1);

$subscriptionItem = $user->subscription('default')->findItemOrFail('chat-plan');

订阅税金

要指定用户支付订阅的税率,需要实现账单模型的 taxRates 方法,并返回一个包含税率 ID 的数组,你可以在 Stripe 后台订阅这些税率:

public function taxRates()
{
    return ['tax-rate-id'];
}

taxRates 方法可以让你基于每个模型使用对应的税率,这对跨越不同国家不同税率的用户很有用。如果你定义了多方案订阅计划,还可以通过在账单模型中实现 planTaxRates 方法为每个方案定义不同的税率:

public function planTaxRates()
{
    return [
        'plan-id' => ['tax-rate-id'],
    ];
}

注: taxRates 方法只能用于订阅支付,如果你使用 Cashier 生成一次性账单,需要手动指定税率。

同步税率

修改 taxRates 方法返回的硬编码税率 ID 时,该用户现有订阅计划上的税金设置都将保持不变。如果你想要更新 taxTaxRates 方法返回的现有订阅计划上的税金,需要调用用户订阅计划实例上的 syncTaxRates 方法:

$user->subscription('default')->syncTaxRates();

这将同时同步所有订阅项税率,所以请确保你已经修改了相应的 planTaxRates 方法。

免税

Cashier 还提供了内置方法通过调用 Stripe API 来判定该顾客是否可以免税,对应的内置方法是账单模型中的 isNotTaxExemptisTaxExempt 以及 reverseChargeApplies

$user = User::find(1);

$user->isTaxExempt();
$user->isNotTaxExempt();
$user->reverseChargeApplies();

Laravel\Cashier\Invoice 对象也提供了这些方法,不过,在 Invoice 对象上调用这些方法时会基于发票创建时间来判定当时的免税状态。

订阅锚定日期

默认情况下,支付周期锚点就是订阅创建的日期,或者如果使用了试用期的话,该日期就是订阅期结束的日子。如果你想要编辑支付锚定日期,可以使用 anchorBillingCycleOn 方法:

use App\Models\User;
use Carbon\Carbon;
    
$user = User::find(1);
    
$anchor = Carbon::parse('first day of next month');
    
$user->newSubscription('default', 'premium')
            ->anchorBillingCycleOn($anchor->startOfDay())
            ->create($paymentMethod);

想要了解更多管理订阅支付周期的信息,可以参考 Stripe 支付周期文档

取消订阅

要取消订阅,可以调用用户订阅上的 cancel 方法:

$user->subscription('default')->cancel();

当订阅被取消时,Cashier 将会自动设置数据库中的 ends_at 字段。该字段用于了解 subscribed 方法什么时候开始返回 false。例如,如果客户3月1号份取消订阅,但订阅直到3月5号才会结束,那么 subscribed 方法继续返回 true 直到3月5号。

你可以使用 onGracePeriod 方法判断用户是否已经取消订阅但仍然在“宽限期”:

if ($user->subscription('default')->onGracePeriod()) {
    //
}

如果你想要立即取消订阅,调用用户订阅上的方法 cancelNow 即可:

$user->subscription('default')->cancelNow();

恢复订阅

如果用户已经取消订阅但想要恢复该订阅,可以使用 resume 方法,前提是该用户必须在宽限期内:

$user->subscription('default')->resume();

如果该用户取消了一个订阅然后在订阅失效之前恢复了这个订阅,则不会立即支付该账单,取而代之的,他们的订阅只是被重新激活,并回到正常的支付周期。

订阅试用期

带有支付方式信息

如果你想要在提供给用户试用期的同时收集用户支付方式,可以在创建订阅的时候使用 trialDays 方法:

$user = User::find(1);
    
$user->newSubscription('default', 'price_monthly')
     ->trialDays(10)
     ->create($paymentMethod);

该方法会在数据库订阅记录上设置试用期结束日期,以便告知 Stripe 在此之前不要计算用户的账单信息。当使用 trialDays 方法时,Cashier 将会覆盖任何默认 Stripe 计划的试用期配置。

注:如果用户的订阅没有在试用期结束之前取消,则会在试用期结束时立即支付,所以要确保通知用户试用期结束时间。

trialUntil 方法允许你提供一个 DateTime 实例来指定试用期什么时候结束:

use Carbon\Carbon;
    
$user->newSubscription('default', 'price_monthly')
        ->trialUntil(Carbon::now()->addDays(10))
        ->create($paymentMethod);

可以使用用户实例或订阅实例上的 onTrial 方法判断用户是否处于试用期,下面两个例子作用是等价的:

if ($user->onTrial('default')) {
    //
}
    
if ($user->subscription('default')->onTrial()) {
    //
}

在 Stripe/Cashier 中定义试用期

你可以选择在 Stripe 后台定义订阅试用期的时长,也可以通过 Cashier 扩展包显式传递这个信息。如果你选择在 Stripe 中定义,需要关注新增的订阅,包括过去曾经订阅过顾客的新订阅,默认都会有一个试用期,除非创建订阅计划时显式调用了 trialDays(0) 方法。

不带支付方式信息

如果你不想在提供试用期的时候收集用户支付方式,只需设置用户记录的 trial_ends_at 字段为期望的试用期结束日期即可,这通常在用户注册期间完成:

$user = User::create([
    // Populate other user properties...
    'trial_ends_at' => Carbon::now()->addDays(10),
]);

注:确保已添加 trial_ends_at 日期修改器到模型定义。

Cashier 将这种类型的试用期看作「通用体验」,因为这种使用并没有附加到任何已经在的订阅,如果当前日期没有超过 trial_ends_at 的值, User 实例上的 onTrial 方法将返回 true

if ($user->onTrial()) {
    // User is within their trial period...
}

如果你想要知道用户是否在“一般”试用期并且还没有创建实际的订阅还可以使用 onGenericTrial 方法:

if ($user->onGenericTrial()) {
    // User is within their "generic" trial period...
}

一旦你准备好为用户创建实际的订阅,可以使用 newSubscription 方法:

$user = User::find(1);

$user->newSubscription('default', 'price_monthly')->create($paymentMethod);

延长试用期

extendTrial 方法可用于在订阅计划创建之后延长试用期:

// 从现在开始,7 天后结束...
$subscription->extendTrial(
    now()->addDays(7)
);

// 在原来试用期基础上再加 5 天...
$subscription->extendTrial(
    $subscription->trial_ends_at->addDays(5)
);

如果试用期已结束并且顾客已经处于付费订阅期,仍然可以延长试用期,延长的试用期会在下一个订阅周期内生效。

处理 Stripe Webhook

注:你可以使用 Stripe CLI 在本地开发期间测试 Webhook。

Stripe 可以通过 webhook 通知应用各种事件,默认情况下,一个指向 Cashier webhook 控制器的路由已经通过 Cashier 服务提供者配置好了,这个控制器将会处理所有进入的 webhook 请求。

默认情况下,这个控制器将会自动对支付失败次数(这个次数可以在 Stripe 设置中定义)过多的订阅进行取消,自动处理顾客更新、删除,以及订阅更新和信用卡更新等操作;此外,我们很快会发现,你可以扩展这个控制器来处理任何你想要处理的 webhook 事件。

为了让应用可以处理 Stripe Webhook,需要确保在 Stripe 控制面板中配置过这个 webhook URL。默认情况下,Cashier 的 webhook 控制器会监听 /stripe/webhook URL 路径。下面是所有你需要在 Stripe 控制面板中配置的 webhooks 完整列表:

  • customer.subscription.updated
  • customer.subscription.deleted
  • customer.updated
  • customer.deleted
  • invoice.payment_action_required

注:确保通过 Cashier 引入的 webhook 签名验证中间件对输入请求进行保护。

Webhooks & CSRF 防护

由于 Stripe webhook 需要绕开 Laravel 的 CSRF 保护,所以需要将其罗列到 VerifyCsrfToken 中间件的排除列表或者将其置于 web 中间件组之外:

protected $except = [
    'stripe/*',
];

定义 Webhook 事件处理器

Cashier 会基于支付失败次数自动取消订阅,但是如果你想要处理额外的 Stripe webhook 事件,扩展 Webhook 控制器即可。定义的方法名需要与 Cashier 约定的格式保持一致,特别是方法名需要以 handle 开头并且是想要处理的 Stripe webhook 的驼峰格式。例如,如果你想要处理 invoice.payment_succeeded webhook,则需要添加一个 handleInvoicePaymentSucceeded 方法到控制器:

<?php
    
namespace App\Http\Controllers;
    
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
    
class WebhookController extends CashierController
{
    /**
     * Handle a Stripe webhook.
     *
     * @param  array  $payload
     * @return Response
     */
    public function handleInvoicePaymentSucceeded($payload)
    {
        // 处理这个事件
    }
}

接下来,在 routes/web.php 中定义一个指向 Cashier 控制器的路由:

use App\Http\Controllers\WebhookController;

Route::post(
    'stripe/webhook',
    [WebhookController::class, 'handleWebhook']
);

Cashier 会在接收到 webhook 时触发 Laravel\Cashier\Events\WebhookReceived 事件,然后在 webhook 被 Cashier 处理之后触发 Laravel\Cashier\Events\WebhookHandled 事件,这两个事件都包含了完整的 Stripe webhook 载荷数据。

失败的订阅

如果用户的信用卡过期怎么办?不用担心 —— Cashier webhook 控制器会为你取消该用户的订阅。失败的支付将会被控制器自动捕获和处理,该控制器将会在 Stripe 判断订阅支付失败次数(通常是 3 次)达到上限时取消该用户的订阅。

验证 Webhook 签名

如果要对 webhook 进行安全加固,可以使用 Stripe 的 webhook 签名。为了方便起见,Cashier 会自动引入验证输入的 Stripe Webhook 请求是否有效的中间件。

要启用 webhook 验证,确保环境配置文件 .env 中的 STRIPE_WEBHOOK_SECRET 配置被设置,改配置值可以从 Stripe 账户后台面板中获取。

一次性支付

基本使用

注:使用 Stripe 时, charge 方法可以接收应用所使用货币对应的最小单位金额。

如果你想要使用订阅客户的信用卡一次性结清账单,可以使用账单模型实例上的 charge 方法,该方法需要传入支付方法标识符作为第二个参数:

// Stripe Accepts Charges In Cents...
$stripeCharge = $user->charge(100, $paymentMethod);

charge 方法接收一个数组作为第三个参数,允许你传递任何你想要传递的底层 Stripe 账单创建参数,创建账单时我们可以参考 Stripe 文档提供的可用选项:

$user->charge(100, $paymentMethod, [
    'custom_option' => $value,
]);

还可以使用不带底层顾客或用户参数的 charge 方法:

use App\Models\User;

$stripeCharge = (new User)->charge(100, $paymentMethod);

如果支付失败 charge 方法将抛出异常,如果支付成功,该方法会返回 Laravel\Cashier\Payment 实例:

try {
    $payment = $user->charge(100, $paymentMethod);
} catch (Exception $e) {
    //
}

带发票的支付

有时候你需要创建一个一次性支付并且同时生成对应发票以便为用户提供一个PDF单据, invoiceFor 方法可以帮助我们实现这个需求。例如,让我们为用户的「一次性费用」生成一张 $5.00 的发票:

// Stripe Accepts Charges In Cents...
$user->invoiceFor('One Time Fee', 500);

该单据会通过用户默认支付方法立即支付, invoiceFor 方法还可以接收一个数组作为第三个参数,该数组包含发票项目的计费选项。该方法的第四个参数也是一个数组,包含的是发票本身的计费选项:

$user->invoiceFor('Stickers', 500, [
    'quantity' => 50,
], [
    'tax_percent' => 21,
]);

注: invoiceFor 方法会创建一个对失败支付进行重试的 Stripe 单据,如果你不想要单据重试失败的支付,需要在首次支付失败后使用 Stripe API 关闭它们。

退款

如果你需要对 Stripe 支付进行退款,可以使用 refund 方法,该方法接收 Stripe 支付 ID 作为唯一参数:

$payment = $user->charge(100, $paymentMethod);

$user->refund($payment->id);

发票

获取发票

你可以使用 invoices 方法轻松获取账单模型的发票数组:

$invoices = $user->invoices();

// Include pending invoices in the results...
$invoices = $user->invoicesIncludingPending();

要获取指定发票,可以使用 findInvoice 方法:

$invoice = $user->findInvoice($invoiceId);

显示发票信息

当列出客户发票时,你可以使用发票的辅助函数来显示相关的发票信息。例如,你可能想要在表格中列出每张发票,从而方便用户下载它们:

<table>
    @foreach ($invoices as $invoice)
    <tr>
        <td>{{ $invoice->date()->toFormattedDateString() }}</td>
        <td>{{ $invoice->total() }}</td>
        <td><a href="/user/invoice/{{ $invoice->id }}">Download</a></td>
    </tr>
    @endforeach
</table>

生成 PDF 发票

在路由或控制器中,使用 downloadInvoice 方法生成发票的 PDF 下载,该方法将会自动生成相应的 HTTP 响应发送下载到浏览器:

use Illuminate\Http\Request;
    
Route::get('user/invoice/{invoice}', function (Request $request, $invoiceId) {
    return $request->user()->downloadInvoice($invoiceId, [
        'vendor'  => 'Your Company',
        'product' => 'Your Product',
    ]);
});

downloadInvoice 方法还支持传递一个可选的自定义文件名作为第三个参数,该文件名会自动添加 .pdf 后缀:

return $request->user()->downloadInvoice($invoiceId, [
    'vendor' => 'Your Company',
    'product' => 'Your Product',
], 'my-invoice');

处理支付失败

有时候,订阅支付或者一次性支付会失败,此时,Cashier 会抛出 IncompletePayment 异常来通知你这个情况。捕获异常后,有两种方式来处理支付失败。

首先,你可以将顾客重定向到 Cashier 自带的支付确认页面,这个页面已经包含了通过 Cashier 服务提供者注册的关联路由,因此,你可以捕获 IncompletePayment 异常然后重定向到这个支付确认页:

use Laravel\Cashier\Exceptions\IncompletePayment;

try {
    $subscription = $user->newSubscription('default', $planId)
                            ->create($paymentMethod);
} catch (IncompletePayment $exception) {
    return redirect()->route(
        'cashier.payment',
        [$exception->payment->id, 'redirect' => route('home')]
    );
}

在支付确认页,顾客会被提示再次输入信用卡信息并执行所有 Stripe 要求的其他额外动作,例如「3D 安全」确认。确认支付后,用户会被重定向到上述代码中 redirect 参数指定的 URL,重定向后,该 URL 中会新增 messagesuccess 查询字符串参数。

此外,你也可以允许 Stripe 为你处理这个支付确认流程,在这种情况下,你需要在 Stripe 后台设置 Stripe 的自动支付邮箱,而不是将用户重定向到支付确认页。不过,如果捕获了 IncompletePayment 异常,你仍然需要通知用户他们会收到一封邮件来处理后续的支付确认流程。

支付异常可能会在如下方法中抛出:chargeinvoiceFor 以及 Billable 用户的 invoice 方法。处理订阅时,SubscriptionBuilder 上的 create 方法、Subscription 上的 incrementAndInvoiceswapAndInvoice 方法也可能抛出异常。

目前还有两种继承 IncompletePayment 的支付异常类型,你可以在需要的时候分别捕获以便自定义用户行为:

  • PaymentActionRequired:表示 Stripe 需要额外的验证以便确认并处理支付;
  • PaymentFailure:表示其他原因导致的支付失败,比如资金不足。

存储顾客认证

如果你的业务主要在欧洲,你可能需要遵守 Strong Customer Authentication(SCA)法规。该发规于 2019 年由欧盟强制实施,以避免付款欺诈。幸运的是,Stripe 和 Cashier 已经为构建兼容 SCA 的应用做好了准备。

注:开始之前,请查看 Stripe SCA 指南以及它们新的 SCA API 文档

要求额外确认的支付

SCA 发规经常要求额外的验证以便确认和处理支付。当这种情况发生时,Cashier 将会抛出 PaymentActionRequired 异常来通知你需要进行额外验证,更多关于这些异常的处理方式请参考前面的异常处理文档

未完成和过期状态

当支付需要额外确认时,订阅的 stripe_status 字段会保持 incompletepast_due 状态,Cashier 会在支付确认完成时立即通过 webhook 自动激活顾客的订阅。

想了解更多关于 incompletepast_due 状态的信息,请参考对应文档

离线支付通知

由于 SCA 法规要求顾客偶尔验证他们的支付细节,即使订阅还是有效的,Cashier 会在离线支付确认需要时发送支付通知给顾客。例如,这将会在订阅更新时发生。Cashier 的支付通知可以通过设置环境变量 CASHIER_PAYMENT_NOTIFICATION 为某个通知类来启用,默认情况下,通知被禁用。当然,Cashier 内置了你可以开箱使用的通知类,不过也可以使用自定义的通知类:

CASHIER_PAYMENT_NOTIFICATION=Laravel\Cashier\Notifications\ConfirmPayment

要确保离线支付确认通知被发送,请验证 Stripe webhooks 已经做好配置,并且 invoice.payment_action_required webhook 在 Stripe 后台被启用,此外,Billable 模型还应该使用了 Laravel 的 Illuminate\Notifications\Notifiable trait。

注:通知即使在顾客手动发起需要额外确认的支付时也会被发送,不幸的是,Stripe 没有办法知道支付是否是动手完成还是「离线」完成。但是,顾客会在确认支付后访问支付页面时看到「支付成功」消息,该顾客将不被允许对同一个支付进行两次确认以免导致第二次付费。

Stripe SDK

大多数 Cashier 对象都是对 Stripe SDK 对象的封装,如果你想要直接和 Stripe 对象打交道,可以通过 asStripe 方法来获取它们:

$stripeSubscription = $subscription->asStripeSubscription();

$stripeSubscription->application_fee_percent = 5;

$stripeSubscription->save();

还可以使用 updateStripeSubscription 方法来直接更新 Stripe 订阅:

$subscription->updateStripeSubscription(['application_fee_percent' => 5]);

测试

要测试一个使用了 Cashier 扩展包的应用,可以模拟针对 Stripe API 发起 HTTP 请求,不过,这需要你部分重新实现 Cashier 自身的行为。因此,我们建议允许你的测试直达实际的 Stripe API。尽管这要慢一些,但是可以更好地确保应用可以按照预期运行,并且任何缓慢的测试都可以放到自己的 PHPUnit 测试组中执行。

在测试过程中,不要忘了 Cashier 扩展包本身已经有了一套优秀的测试套件,所以你只需要专注测试自己编写的订阅和支付流程业务代码而不需要去测试 Cashier 底层的行为。

开始之前,添加一个测试版的 Stripe 密钥到 phpunit.xml 文件:

<env name="STRIPE_SECRET" value="sk_test_<your-key>"/>

这样一来,无论何时在测试中与 Cashier 扩展包交互,都会发送实际 API 请求到 Stripe 测试环境,为了方便起见,你需要将订阅计划预填写到 Stripe 测试账号,这样在测试时就可以使用它们了。

注:为了能够测试不同的支付场景,比如信用卡遭拒绝和支付失败,可以使用 Stripe 提供的大量测试卡号和令牌作为测试数据源。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 模拟

>> 下一篇: 基于 Laravel Envoy 提供远程部署解决方案