Laravel 的收费系统 Cashier

优质
小牛编辑
180浏览
2023-12-01

简介

Laravel Cashier 提供了直观、流畅的接口来接入 Stripe'sBraintree's 订阅付费服务。它可以处理几乎所有你写起来非常头疼付费订阅代码。除了提供基本的订阅管理之外,Cashier 还可以帮你处理优惠券,交换订阅、订阅“数量”、取消宽限期,甚至还可以生成pdf文档。

{note} 如果你只是“一次性”的收费并且不提供订阅。你就不应该使用 Cashier 。可以直接使用 Stripe 和 Braintree 的 sdk。

配置

Stripe

Composer

首先, 将 Stripe 的 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();
});

一旦迁移文件建立好后,运行 Artisan 的 migrate 命令。

Billable 模型

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

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

API Keys

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

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

Braintree

Braintree 注意事项

对于很多操作,Stripe 和 Braintree 实现的 Cashier 功能都是一样的,两者都提供了通过信用卡进行订阅支付的功能,但是 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"

Service Provider

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

Laravel\Cashier\CashierServiceProvider::class

信用卡优惠计划

在使用 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 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 方法的第一个参数应该是订阅的名称。如果您的应用程序只提供一个订阅,那么您可以将其设置为 main 或者 primary
第二个参数是用户订阅的 Stripe / Braintree 计划。这个值应该与 Stripe 或 Braintree 中的标识符对应。

create 方法接受一个 Stripe 信用卡 / 源令牌,它将开始订阅,并使用客户 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');

订阅量

{note} 订阅数量仅由 Cashier 的 Stripe 支持。Braintree 没有一个对应于 Stripe 的「数量」的特性。

有些时候订阅是会受「数量」影响的。举个例子,你的应用程序的付费方式可能是 每个账户 $10 /月。你可以使用 incrementQuantitydecrementQuantity 方法轻松的增加或者减少你的订阅数量:

$user = User::find(1);

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

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

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

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

或者,您可以使用 updateQuantity 方法设置一个特定的数量:

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

noProrate 方法可用于更新订阅的数量,而不会对收费进行定价:

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

要获得更多关于订阅量的信息,请参考 Stripe 文档

订阅税额

要指定用户在订阅中支付的税率百分比,在计费模式上实现 taxPercentage 方法,并在 0 到 100 之间返回一个数值,不超过 2 个小数点。

public function taxPercentage() {
    return 20;
}

taxPercentage 方法使你能够在模型的基础上应用税率,这对于一个跨越多个国家和税率的用户群来说可能是有帮助的。

{note} taxPercentage 方法只适用于订阅付费模式。如果你用 charges 来做 完成一次性收费的,你需要手工指定税率。

取消订阅

要取消订阅,只需在用户的订阅上调用 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 在此之前不要计算用户的账单信息。

{note} 果客户没有在试用期结束之前取消订阅,订阅会被自动结算,所以需要在试用期结束之前通知你的客户。

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

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

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

没有信用卡的情况下

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

$user = User::create([
    // 填充其他用户属性...
    'trial_ends_at' => now()->addDays(10),
]);

{note} 确保已添加 trial_ends_at 日期修改器 到模型定义。

Cashier 把这种类型的试用引用为「一般体验」。因为它并没有关联任何已存在的订阅。如果当前的日期没有超过 trial_ends_at 的值, 那么 User 实例上的 onTrial 方法将返回 true

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

如果您希望明确地知道用户处于「generic」的试用期,并且还没有创建实际的订阅,那么您可以使用 onGenericTrial 方法:

if ($user->onGenericTrial()) {
    // 用户在 「generic」 试用期内...
}

一旦你准备好为用户创建实际的订阅,可以使用 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'
);

{note} 一旦注册好控制器后,还要在 Stripe 控制面板中配置 webhook URL。

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

Webhooks & CSRF 保护

因为 Stripe webhooks 需要绕过 Laravel 的 CSRF 保护,一定要在 VerifyCsrfToken 中间件中列出 URI ,或者将其置于 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
{
    /**
     * 处理 Stripe webhook.
     *
     * @param  array  $payload
     * @return Response
     */
    public function handleInvoicePaymentSucceeded($payload)
    {
        // 处理事件
    }
}

接下来, 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'
);

{note} 一旦注册好控制器后,还要在 Braintree 控制面板中配置 webhook URL

默认情况下,该控制器将自动处理在多次失败的支付尝试后自动取消该订阅(根据您的 Braintree 设置所定义);但是,我们很快就会发现,您可以扩展这个控制器来处理任何您喜欢的 webhook 事件。

Webhooks & CSRF 保护

因为 Braintree webhooks 需要绕过 Laravel 的 CSRF 保护,所以一定要在 VerifyCsrfToken 中间件中列出这个 URI ,或者列出 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
{
    /**
     *  处理 Braintree webhook.
     *
     * @param  WebhookNotification  $webhook
     * @return Response
     */
    public function handleDisputeOpened(WebhookNotification $notification)
    {
        // 处理事件
    }
}

订阅失败

如果客户的信用卡过期了怎么办?不用担心,Cashier 会有一个 Webhook 控制器,它可以很容易地取消客户的订阅。如上所述,您所需要做的就是指定一个路由到该控制器:

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

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

一次性支付

简单支付

{note} 当使用 Stripe 时, charge 法会接收 应用所使用货币对应的最小单位金额 。然而,当使用 Braintree 时,您应该将全部的美元金额传递给 charge 方法:

如果你想要使用订阅客户的信用卡「一次行」结清账单,那么您可以在 billable 模型实例上使用 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 方法时包含一个「描述」选项:

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

{note} 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>[Download](/user/invoice/{{ $invoice->id }})</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',
    ]);
});