订阅支付解决方案:Laravel Cashier(Braintree)

简介

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

注:该文档适用于 Cashier 与 Braintree 的集成,如果你在使用 Stripe,请参考 Stripe 集成文档

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

注意事项

对于绝大多数操作而言,Cashier 的 Stripe 和 Braintree 实现功能都是一样的,两个服务都提供了通过信用卡进行订阅支付的功能,Braintree 还支持通过 Paypal 进行支付。不过,Braintree 缺少了一些 Stripe 所支持的功能,在决定使用 Stripe 还是 Braintree 之前,需要权衡以下因素:

  • Braintree 支持 Paypal 而 Stripe 不支持;
  • Braintree 不支持在订阅上调用 incrementdecrement 方法,这是 Braintree 的局限,而不是 Cashier 的;
  • Braintree 不支持基于百分比的折扣,这也是 Braintree 的局限,而不是 Cashier 的。

安装

首先,需要通过 Composer 安装适用于 Braintree 的 Cashier 扩展包:

composer require laravel/cashier-braintree

配置

计划信用优惠券

在使用基于 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 密钥

接下来,需要在配置文件 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($token);

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

create 方法接收信用卡/源令牌,会自动创建这个订阅,同时更新数据库中 对应订阅的客户 ID 和其它相关的账单信息。

额外的用户信息

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

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

要了解更多 Braintree 支持的字段,可以查看相应的Braintree文档

优惠券

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

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

检查订阅状态

用户订阅你的应用后,你可以使用各种便利的方法来简单检查订阅状态。首先,如果用户有一个有效的订阅,则 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')) {
    //
}

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

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

已取消的订阅状态

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

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

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

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

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

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

修改计划

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

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

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

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

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

订阅税金

要指定用户支付订阅的税率,实现账单模型的 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();

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

订阅试用期

带信用卡信息

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

$user = User::find(1);

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

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

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

可以使用用户实例或订阅实例上的 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($token);

顾客

创建顾客

少数情况下,你可能希望创建一个没有开始订阅的 Braintree 顾客,这可以通过 createAsStripeCustomer 方法来实现:

$user->createAsStripeCustomer();

一旦在 Braintree 中创建这样的顾客后,需要在稍晚时候让他开始订阅。

信用卡

更新信用卡

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

$user->updateCard($token);

处理 Webhooks

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

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

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

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

Webhooks & CSRF 防护

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

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

定义 Webhook 事件处理器

Cashier 会基于支付失败次数自动取消订阅,但是如果你想要处理额外的 webhook 事件,扩展 Webhook 控制器即可。定义的方法名需要与 Cashier 约定的格式保持一致,特别是方法名需要以 handle 开头并且是想要处理的 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 new dispute.
     *
     * @param  \Braintree\WebhookNotification  $webhook
     * @return \Symfony\Component\HttpFoundation\Responses
     */
    public function handleDisputeOpened(WebhookNotification $webhook)
    {
        // Handle The Webhook...
    }
}

失败的订阅

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

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

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

一次性支付

简单支付

注:必须传递完整的美元金额到 charge 方法。

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

$user->charge(1);

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

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

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

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

带发票的支付

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

$user->invoiceFor('One Time Fee', 5);

该单据会通过用户信用卡立即支付, invoiceFor 方法还可以接收一个数组作为第三个参数,该数组包含发票项目的计费选项。在调用 invoiceFor 方法时必须包含 description 选项:

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

发票

你可以使用 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 发票

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

上一篇: 订阅支付解决方案:Laravel Cashier(Stripe)

下一篇: 远程操作解决方案:Laravel Envoy