订阅支付解决方案:Laravel Cashier


简介

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

注:如果你只需要一次性支付,并不提供订阅,就不应该使用 Cashier,而是直接使用 Stripe 和 Braintree SDK。

配置

Stripe

Composer

首先,通过 Composer 安装 Cashier 扩展包依赖:

composer require "laravel/cashier":"~7.0"

数据库迁移

使用 Cashier 之前,我们需要准备好数据库。我们需要添加一个字段到 users 表,还要创建新的 subscriptions 表来处理所有用户订阅:

Schema::table('users', function ($table) {
    $table->string('stripe_id')->nullable();
    $table->string('card_brand')->nullable();
    $table->string('card_last_four')->nullable();
    $table->timestamp('trial_ends_at')->nullable();
});

Schema::create('subscriptions', function ($table) {
    $table->increments('id');
    $table->integer('user_id');
    $table->string('name');
    $table->string('stripe_id');
    $table->string('stripe_plan');
    $table->integer('quantity');
    $table->timestamp('trial_ends_at')->nullable();
    $table->timestamp('ends_at')->nullable();
    $table->timestamps();
});

创建好迁移后,只需简单运行 migrate 命令,相应修改就会更新到数据库。

Billable 模型

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

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

API Key

最后,在配置文件 services.php 中配置 Stripe 的 key,你可以在Stripe 官网个人中心的控制面板中获取这些 Stripe API key 信息:

'stripe' => [
    'model'  => App\User::class,
    'key' => env('STRIPE_KEY'),
    'secret' => env('STRIPE_API_SECRET'),
],

Braintree

Braintree 注意事项

对于很多操作,Stripe 和 Braintree 实现的 Cashier 功能都是一样的,两者都提供了通过信用卡进行订阅支付的功能,但是 Braintree 还支持通过 PayPal 支付。不过,Braintree 也缺失一些 Stripe 支持的功能,在决定使用 Stripe 还是 Braintree 之前,需要考虑以下几点:

  • Braintree 支持 PayPal 而 Stripe 不支持;
  • Braintree 不支持 incrementdecrement 方法,这个是 Braintree 级别的限制,不是 Cashier 级别的;
  • Braintree 不支持基于百分比的折扣,这个也是 Braintree 级别的限制,不是 Cashier 级别的

Composer

首先,通过 Composer 安装 Cashier 扩展包依赖:

composer require "laravel/cashier-braintree":"~2.0"

服务提供者

接下来,在配置文件 config/app.php 中注册服务提供者 Laravel\Cashier\CashierServiceProvider

计划信用优惠券

在使用基于 Braintree 的 Cashier 之前,需要在 Braintree 的控制面板中定义一个 plan-credit 折扣,这个折扣将会用于从年到月或者从月到年支付的优惠额度比例分配。

配置在 Braintree 控制面板中的这个折扣值可以是你希望的任何值,Cashier 将会在每次使用优惠券的时候通过自定义的值来重写这个默认定义的值。该优惠券之所以是必须的是因为 Braintree 不支持本地根据订阅时长进行优惠额度的按比例分配。

数据库迁移

使用 Cashier 之前,需要准备好数据库。我们需要添加额外的字段到 users 表并创建一个新的 subscriptions 表用于存放所有用户订阅:

Schema::table('users', function ($table) {
    $table->string('braintree_id')->nullable();
    $table->string('paypal_email')->nullable();
    $table->string('card_brand')->nullable();
    $table->string('card_last_four')->nullable();
    $table->timestamp('trial_ends_at')->nullable();
});

Schema::create('subscriptions', function ($table) {
    $table->increments('id');
    $table->integer('user_id');
    $table->string('name');
    $table->string('braintree_id');
    $table->string('braintree_plan');
    $table->integer('quantity');
    $table->timestamp('trial_ends_at')->nullable();
    $table->timestamp('ends_at')->nullable();
    $table->timestamps();
});

迁移被创建之后,只需要执行 Artisan 命令 migrate 即可。

Billable 模型

接下来,添加 Billable trait 到模型定义:

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

API Key

接下来,需要在配置文件 services.php 中配置如下选项:

'braintree' => [
    'model'  => App\User::class,
    'environment' => env('BRAINTREE_ENV'),
    'merchant_id' => env('BRAINTREE_MERCHANT_ID'),
    'public_key' => env('BRAINTREE_PUBLIC_KEY'),
    'private_key' => env('BRAINTREE_PRIVATE_KEY'),
],

然后你需要在服务提供者 AppServiceProviderboot 方法中添加对 Braintree SDK 的调用:

\Braintree_Configuration::environment(config('services.braintree.environment'));
\Braintree_Configuration::merchantId(config('services.braintree.merchant_id'));
\Braintree_Configuration::publicKey(config('services.braintree.public_key'));
\Braintree_Configuration::privateKey(config('services.braintree.private_key'));

货币配置

Cashier 默认货币是美元(USD),你可以在某个服务提供者的 boot 方法中通过调用 Cashier::useCurrency 方法来更改默认的货币, useCurrency 方法接收两个字符串参数:货币及货币符号:

use Laravel\Cashier\Cashier;

Cashier::useCurrency('rmb', '¥');

订阅

创建订阅

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

$user = User::find(1);

$user->newSubscription('main', 'premium')->create($stripeToken);

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

create 方法会自动创建这个 Stripe 订阅,同时更新数据库中 Stripe 的客户 ID(即 users 表中的 stripe_id)和其它相关的账单信息。

额外的用户信息

如果你想要指定额外的客户信息,你可以将其作为第二个参数传递给 create 方法:

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

要了解更多 Stripe 支持的字段,可以查看 Stripe 关于创建消费者的文档或者相应的Braintree文档

优惠券

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

$user->newSubscription('main', 'monthly')
     ->withCoupon('code')
     ->create($stripeToken);

检查订阅状态

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

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

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

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

    return $next($request);
}

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

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

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

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

已取消的订阅状态

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

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

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

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

修改计划

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

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

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

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

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

订阅数量

注:只有 Stripe 版本的 Cashier 支持订阅数量,Braintree 没有提供类似 Stripe 「数量」这样的特性。

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

$user = User::find(1);

$user->subscription('main')->incrementQuantity();

// Add five to the subscription's current quantity...
$user->subscription('main')->incrementQuantity(5);

$user->subscription('main')->decrementQuantity();

// Subtract five to the subscription's current quantity...
$user->subscription('main')->decrementQuantity(5);

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

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

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

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

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

订阅税金

要指定用户支付订阅的税率,实现账单模型的 taxPercentage 方法,并返回一个在0到100之间的数值,不要超过两位小数:

public function taxPercentage() {
    return 20;
}

这将使你可以在模型基础上使用税率,对跨越不同国家不同税率的用户很有用。

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

取消订阅

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

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

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

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

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

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

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

恢复订阅

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

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

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

更新信用卡

updateCard 方法可用于更新用户的信用卡信息,该方法接收一个 Stripe 令牌并设置新的信用卡作为支付源:

$user->updateCard($stripeToken);

订阅试用期

带信用卡信息

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

$user = User::find(1);

$user->newSubscription('main', 'monthly')
     ->trialDays(10)
     ->create($stripeToken);

该方法会在数据库订阅记录上设置试用期结束日期,以便告知 Stripe/Braintree 在此之前不要计算用户的账单信息。

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

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

use Carbon\Carbon;

$user->newSubscription('main', 'monthly')
        ->trialUntil(Carbon::now()->addDays(10))
        ->create($stripeToken);

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

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

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

不带信用卡信息

如果你不想在提供试用期的时候收集用户支付方式信息,只需设置用户记录的 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('main', 'monthly')->create($stripeToken);

处理 Stripe Webhooks

Stripe 和 Braintree 都可以通过 webhooks 通知应用各种事件,要处理 Stripe webhooks,需要定义一个指向 Cashier webhook 控制器的路由,这个控制器将会处理所有输入 webhook 请求并将它们分发到合适的控制器方法:

Route::post(
    'stripe/webhook',
    '\Laravel\Cashier\Http\Controllers\WebhookController@handleWebhook'
);

注:注册好控制器后,还要在 Stripe 控制面板中配置 webhook URL。

默认情况下,这个控制器将会自动对支付失败次数(这个次数可以在 Stripe 设置中定义)过多的订阅进行取消;此外,我们很快会发现,你可以扩展这个控制器来处理任何你想要处理的 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)
    {
        // Handle The Event
    }
}

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

Route::post(
    'stripe/webhook',
    '\App\Http\Controllers\WebhookController@handleWebhook'
);

失败的订阅

如果用户的信用卡过期怎么办?不用担心 —— Cashier webhook 控制器可以轻松为你取消该用户的订阅,正如上面所提到的,你所需要做的只是将路由指向该控制器:

Route::post(
    'stripe/webhook',
    '\Laravel\Cashier\Http\Controllers\WebhookController@handleWebhook'
);

就是这样,失败的支付将会被控制器捕获和处理,该控制器将会在 Stripe 判断订阅支付失败次数(通常是3次)达到上限时取消该用户的订阅。

处理 Braintree Webhooks

Stripe 和 Braintree 都可以通过 webhooks 通知应用各种事件,要处理 Braintree webhooks,需要定义一个指向 Cashier webhook 控制器的路由,这个控制器将会处理所有输入 webhook 请求并将它们分发到合适的控制器方法:

Route::post(
    'braintree/webhook',
    '\Laravel\Cashier\Http\Controllers\WebhookController@handleWebhook'
);

注:注册好控制器后,还要在 Braintree 控制面板中配置 webhook URL。

默认情况下,这个控制器将会自动对支付失败次数(这个次数可以在 Braintree 设置中定义)过多的订阅进行取消;此外,我们很快会发现,你可以扩展这个控制器来处理任何你想要处理的 webhook 事件。

Webhooks & CSRF 防护

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

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

定义 Webhook 事件处理器

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

<?php

namespace App\Http\Controllers;

use Braintree\WebhookNotification;
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;

class WebhookController extends CashierController
{
    /**
     * Handle a Braintree webhook.
     *
     * @param  WebhookNotification  $webhook
     * @return Response
     */
    public function handleDisputeOpened(WebhookNotification $notification)
    {
         // Handle The Event
    }
}

失败的订阅

如果用户的信用卡过期怎么办?不用担心 —— Cashier webhook 控制器可以轻松为你取消该用户的订阅,正如上面所提到的,你所需要做的只是将路由指向该控制器:

Route::post(
    'braintree/webhook',
    '\Laravel\Cashier\Http\Controllers\WebhookController@handleWebhook'
);

就是这样,失败的支付将会被控制器捕获和处理,该控制器将会在 Braintree 判断订阅支付失败次数(通常是3次)达到上限时取消该用户的订阅。不要忘记在 Braintree 控制面板中配置 webhook URI。

一次性支付

简单支付

注:使用 Stripe 时, charge 方法可以接收应用所使用货币对应的最小单位金额,但是使用 Braintree 时,必须传递完整的美元金额到 charge 方法。

如果你想要使用订阅客户的信用卡一次性结清账单,可以使用账单模型实例上的 charge 方法:

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

// Braintree Accepts Charges In Dollars...
$user->charge(1);

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

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

如果支付失败 charge 方法将抛出异常,如果支付成功,该方法会返回完整的 Stripe / Braintree 响应:

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

带发票的支付

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

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

// Braintree Accepts Charges In Dollars...
$user->invoiceFor('One Time Fee', 5);

该单据会通过用户信用卡立即支付, invoiceFor 方法还可以接收一个数组作为第三个参数,从而允许你传递任意你希望的选项到底层 Stripe/Braintree 支付创建:

$user->invoiceFor('One Time Fee', 500, [
    'custom-option' => $value,
]);

如果你使用 Braintree 作为支付提供方,则必须在调用 invoiceFor 时包含 description 选项:

$user->invoiceFor('One Time Fee', 500, [
    'description' => 'your invoice description here',
]);

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

发票

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

$invoices = $user->invoices();

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

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

<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 发票

在生成 PDF 发票之前,需要安装 PHP 库 dompdf

composer require dompdf/dompdf

然后,在路由或控制器中,使用 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',
    ]);
});

Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 模拟

>> 下一篇: 远程操作解决方案:Envoy Task Runner