Laravel Cashier

简介

Laravel 为 StripeBraintree 的订阅账单服务提供了直观流畅的接口。它几乎处理了所有害怕编写的固定格式的订阅账单代码。除了基本的订阅管理外,Cashier 还可以处理优惠券,更改订阅,订阅「数量」,取消宽限期,甚至生成发票 PDF。

如果只进行「一次性」收费并且不提供订阅,那么不应该使用 Cashier。而是,直接使用 Stripe 和 Braintree 的 SDK。

配置

Stripe

Composer

首先,将 Stripe 的 Cashier 扩展包添加到依赖中:

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

数据库迁移

使用 Stripe 前,还需要 准备数据库。我们需要添加几个字段到 users 数据表,并创建一个新的存放所有客户订阅的 subscriptions 数据表:

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

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

创建迁移后,运行 Artisan 命令 migrate

Billable 模型

接下来,在模型定义中添加 Billable Trait。此 Trait 提供各种方法来完成常见的账单任务,例如创建订阅,使用优惠券和更新信用卡信息:

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

API Keys

最后,在 services.php 配置文件中配置 Stripe 密钥。可以从 Stripe 控制面板获取 Stripe API 密钥:

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

Braintree

Braintree 附加说明

对于很多操作,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":"~2.0"

服务提供者

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

Laravel\Cashier\CashierServiceProvider::class

信用卡优惠计划

在使用 Cashier 的 Braintree 前,需要在 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->unsignedInteger('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 Keys

接着,在 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'),
],

然后,在 AppServiceProvider 服务提供者的 boot 方法中添加下列 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('eur', '€');

订阅

创建订阅

要创建订阅,首先获取一个 Billable 模型实例,通常会是 App\User 的实例。获取模型实例后,可以使用 newSubscription 方法创建模型的订阅:

$user = User::find(1);

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

第一个传递给 newSubscription 方法的参数是订阅名称。如果应用只提供单个订阅,可以称其为 mainprimary。第二个参数是用户订阅的具体的 Stripe / Braintree 计划。此值对应于 Stripe 或 Braintree 中的计划标识符。

接收 Stripe 信用卡/源令牌的 create 方法会开始订阅,同时将客户 ID 和其它相关账单信息更新到数据库中。

用户其它详情

如果想要指定其它客户详情,可以通过将其作为第二个参数传递给 create 方法:

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

要了解 Stripe 或 Braintree 支持的更多其它字段的更多信息,可以查看 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')) {
        // 此用户不是付款客户
        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();

// 当前订阅数量加五
$user->subscription('main')->incrementQuantity(5);

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

// 当前订阅数量减五
$user->subscription('main')->decrementQuantity(5);

或者,还可以使用 updateQuantity 方法设置指定数量:

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

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

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

有关订阅数量的更多信息,可以查看 Stripe 文档

订阅税费

要指定用户为订阅支付的税率,可以在 Billable 模型上实现 taxPercentage 方法,并返回不超过 2 个小数位的 0 到 100 的数值。

public function taxPercentage() {
    return 20;
}

taxPercentage 方法允许您将税率逐个应用到模型上,这可能对跨越多个国家和税率的用户有帮助。

taxPercentage 方法只用于订阅费用。如果使用 Cashier 进行「一次性」收费,需要手动指定当时的税率。

同步更新税费百分比

当更改 taxPercentage 方法返回的硬编码值时,用户的任何已有订阅的税费设置都将保持不变。如果希望使用返回的 taxPercentage 值更新已有订阅的税值,可以在用户的订阅实例上调用 syncTaxPercentage 方法:

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

订阅结算日期

修改订阅的结算日期只支持 Stripe 版本的 Cashier。

默认情况下,账单结算周期是创建订阅的日期,或者如果使用试用期,则是试用结束的日期。如果想要修改账单结算日期,可以使用 anchorBillingCycleOn 方法:

use App\User;
use Carbon\Carbon;

$user = User::find(1);

$anchor = Carbon::parse('first day of next month');

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

有关管理订阅结算周期,可以查看 Stripe 结算周期文档

取消订阅

要取消订阅,可以在用户的订阅上调用 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 方法,或订阅实例的 onTrial 方法判断用户是否在试用期内。以下两个示例是等效的:

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

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

没有信用卡

如果想要在未预先手机用户付款方式信息的情况下提供试用期,可以将用户记录中的 trial_ends_at 字段设置为所需的试用结束日期。这通常在用户注册时完成:

$user = User::create([
    // 设置其它用户属性
    'trial_ends_at' => now()->addDays(10),
]);

确保在模型定义时为 trial_ends_at 添加了 日期修改器

Cashier 将此类试用称为「通用试用」,因为它不添加到任何已有订阅。如果当前日期不是没有超过 trial_ends_at 的值,User 实例上的 onTrial 方法将返回 true

if ($user->onTrial()) {
    // 用户在试用期中
}

如果希望明确知道用户处于「通用」试用期并且尚未创建实际订阅,也可以使用 onGenericTrial 方法:

if ($user->onGenericTrial()) {
    // 用户在「通用」试用期中
}

准备好为用户创建实际订阅后,可以像往常一样使用 newSubscription 方法:

$user = User::find(1);

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

客户

创建客户

有时可能希望在不开始订阅的情况下创建 Stripe 客户。可以试用 createAsStripeCustomer 方法完成此操作:

$user->createAsStripeCustomer($stripeToken);

当然,在 Stripe 中创建客户后,可以在稍后开始订阅。

Braintree 中和此方法等效的是 createAsBraintreeCustomer 方法。

处理 Stripe Webhooks

Stripe 和 Braintree 都可以通过 Webhook 向应用通知各种事件。要处理 Stripe Webhook,可以指定指向 Cashier 的 Webhook 控制器的路由。此控制器会处理所有传入的 Webhook 请求并将其分发到适当的控制器方法:

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

注册路由后,确保在 Stripe 控制面板设置中配置 Webhook URL。

默认情况下,此控制器会自动处理取消订阅,这些订阅包含太多的失败请求(通过 Stripe 设置定义);但是,我们会很快发现,您可以继承此控制器处理要处理的任何 Webhook。

Webhooks & CSRF 保护

由于 Stripe Webhooks 需要绕过 Laravel 的 CSRF 保护,确保将 URI 排除在 VerifyCsrfToken 中间件之外或者将路由列在 web 中间件组之外:

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

定义 Webhook 事件处理程序

Cashier 会自动处理失败请求的取消订阅,但是如果有想要处理的其它 Stripe Webhook 事件,可以继承 Webhook 控制器。方法名应该与 Cashier 预期约定相对应,具体而言,方法应以 handle 为希望处理的 Stripe Webhook 名的「驼峰命名法」的前缀。例如,如果希望处理 invoice.payment_succeeded Webhook,可以在控制器中添加 handleInvoicePaymentSucceeded 方法:

namespace App\Http\Controllers;

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

class WebhookController extends CashierController
{
    /**
     * 处理 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 确定订阅失败时(通常在三次付款尝试失败后),控制器将取消客户的订阅。

验证 Webhook 签名

要保护 Webhook,可以使用 Stripe 的 Webhook 签名。为方便起见,Cashier 包含一个中间件,验证传入的 Stripe Webhook 请求是有效的。

首先,确保在 services 配置文件中设置了 stripe.webhook.secret 配置值。配置 Webhook 秘钥后,可以将 VerifyWebhookSignature 中间件添加到路由:

use Laravel\Cashier\Http\Middleware\VerifyWebhookSignature;

Route::post(
    'stripe/webhook',
    '\App\Http\Controllers\WebhookController@handleWebhook'
)->middleware(VerifyWebhookSignature::class);

处理 Braintree Webhooks

Stripe 和 Braintree 都可以通过 Webhook 向应用通知各种事件。要处理 Braintree Webhook,可以指定指向 Cashier 的 Webhook 控制器的路由。此控制器会处理所有传入的 Webhook 请求并将其分发到适当的控制器方法:

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

注册路由后,确保在 Braintree 控制面板设置中配置 Webhook URL。

默认情况下,此控制器会自动处理取消订阅,这些订阅包含太多的失败请求(通过 Braintree 设置定义);但是,我们会很快发现,您可以继承此控制器处理要处理的任何 Webhook。

Webhooks & CSRF 保护

由于 Braintree Webhooks 需要绕过 Laravel 的 CSRF 保护,确保将 URI 排除在 VerifyCsrfToken 中间件之外或者将路由列在 web 中间件组之外:

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

定义 Webhook 事件处理程序

Cashier 会自动处理失败请求的取消订阅,但是如果有想要处理的其它 Braintree Webhook 事件,可以继承 Webhook 控制器。方法名应该与 Cashier 预期约定相对应,具体而言,方法应以 handle 为希望处理的 Braintree Webhook 名的「驼峰命名法」的前缀。例如,如果希望处理 dispute_opened Webhook,可以在控制器中添加 handleDisputeOpened 方法:

namespace App\Http\Controllers;

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

class WebhookController extends CashierController
{
    /**
     * 处理 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'
);

仅此而已!控制器将捕获并处理失败的付款。当 Stripe 确定订阅失败时(通常在三次付款尝试失败后),控制器将取消客户的订阅。不要忘记:您需要在 Braintree 控制面板设置中配置 Webhook URI。

单次收费

简单收费

当使用 Stripe 时,charge 方法接收想要以应用使用的货币的最小单位支付的金额。然而,当使用 Braintree 时,应该传递完整的美元金额给 charge 方法。

如果想要对订阅的客户的信用卡进行「一次性」收费,可以在 Billable 模型实例上使用 charge 方法:

// Stripe 按分接收费用
$stripeCharge = $user->charge(100);

// Braintree 按美元接收费用
$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 按分接收费用
$user->invoiceFor('One Time Fee', 500);

// Braintree 按美元接收费用
$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 关闭它们。

退还收费

如果需要退还 Stripe 收费,可以使用 refund 方法。此方法接收 Stripe 收费 ID 作为其唯一参数:

$stripeCharge = $user->charge(100);

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

发票

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

$invoices = $user->invoices();

// 将处理中的发票包含到结果中
$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',
    ]);
});