订阅支付解决方案:Laravel Cashier
简介
Laravel Cashier 为通过 Stripe 和 Braintree 实现订阅支付服务提供了一个优雅的流式接口。它封装了几乎所有你恐惧编写的样板化的订阅支付代码。除了基本的订阅管理外,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 不支持
increment
和decrement
方法,这个是 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'),
],
然后你需要在服务提供者 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('rmb', '¥');
订阅
创建订阅
要创建一个订阅,首先要获取一个账单模型的实例,通常是 App\User
的实例。获取到该模型实例之后,你可以使用 newSubscription
方法来创建该模型的订阅:
$user = User::find(1);
$user->newSubscription('main', 'premium')->create($stripeToken);
第一个传递给 newSubscription
方法的参数是该订阅的名字,如果应用只有一个订阅,可以将其称作 main
或 primary
,第二个参数用于指定用户订阅的 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,要简单增加或减少订阅数量,使用 incrementQuantity
和 decrementQuantity
方法:
$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',
]);
});
No Comments