简介

Laravel Cashier Stripe 为 Stripe 的订阅计费服务提供了富有表现力且流畅的接口。它能处理几乎所有你不愿编写的样板式订阅计费代码。除了基础的订阅管理功能外,Cashier 还能处理优惠券、订阅切换、订阅“数量”、取消宽限期,甚至能生成发票 PDF。

升级 Cashier

当升级到 Cashier 的新版本时,请务必仔细查阅升级指南。

Cashier 使用的 Stripe API 版本由你所安装的 stripe/stripe-php SDK 锁定。例如,stripe/stripe-php 21.x 使用的是 Stripe API 版本 2026-08-26.dahlia。由于 Stripe SDK 的新主版本通常会针对新的 Stripe API 版本,因此在将 stripe/stripe-php 升级到新的主版本之前,你应该查阅Stripe 的 API 变更日志。

安装

首先,使用 Composer 包管理器安装 Cashier for Stripe 包:

composer require laravel/cashier

安装该包后,使用 vendor:publish Artisan 命令发布 Cashier 的迁移文件:

php artisan vendor:publish --tag="cashier-migrations"

然后,对数据库执行迁移:

php artisan migrate

Cashier 的迁移文件会向你的 users 表中添加多个列。同时,它还会创建一个新的 subscriptions 表,用于保存你所有客户的订阅,以及一个用于存储包含多个价格订阅的 subscription_items 表。

如有需要,你也可以使用 vendor:publish Artisan 命令发布 Cashier 的配置文件:

php artisan vendor:publish --tag="cashier-config"

最后,为确保 Cashier 能够正确处理所有 Stripe 事件,请记得配置 Cashier 的 Webhook 处理。

Stripe 建议,任何用于存储 Stripe 标识符的列都应区分大小写。因此,在使用 MySQL 时,应确保 stripe_id 列的排序规则(collation)设置为 utf8_bin。更多相关信息,请参阅Stripe 文档。

配置

可计费模型

在使用 Cashier 之前,请在可计费模型定义中添加 Billable 特性(trait)。通常,该模型为 App\Models\User。该特性提供了多种方法,让你能够执行常见的计费任务,例如创建订阅、应用优惠券以及更新支付方式信息:

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Cashier 默认认为你的可计费模型是 Laravel 自带的 App\Models\User 类。如果你希望更改此设置,可以通过 useCustomerModel 方法指定其他模型。该方法通常应在 AppServiceProvider 类的 boot 方法中调用:

use App\Models\Cashier\User;
use Laravel\Cashier\Cashier;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Cashier::useCustomerModel(User::class);
}

如果你使用的模型并非 Laravel 提供的 App\Models\User 模型,你需要发布并修改所提供的 Cashier 迁移文件,使其与你替代模型的表名相匹配。

API 密钥

接下来,你应在应用程序的 .env 文件中配置 Stripe API 密钥。你可以从 Stripe 控制台获取 Stripe API 密钥:

STRIPE_KEY=your-stripe-key
STRIPE_SECRET=your-stripe-secret
STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret

请确保在应用程序的 .env 文件中定义 STRIPE_WEBHOOK_SECRET 环境变量,因为该变量用于验证传入的 Webhook 确实来自 Stripe。

货币配置

Cashier 默认使用的货币是美元(USD)。你可以通过在应用程序的 .env 文件中设置 CASHIER_CURRENCY 环境变量来更改默认货币:

CASHIER_CURRENCY=eur

除了配置 Cashier 的货币外,你还可以在格式化用于发票展示的金额时指定一个区域设置(locale)。在内部,Cashier 使用PHP 的 NumberFormatter 类来设置货币区域:

CASHIER_CURRENCY_LOCALE=nl_BE

若要使用 en 以外的区域设置,请确保服务器上已安装并配置了 ext-intl PHP 扩展。

税费配置

借助 Stripe Tax,可以为 Stripe 生成的所有发票自动计算税费。你可以在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中调用 calculateTaxes 方法,以此启用自动税费计算:

use Laravel\Cashier\Cashier;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Cashier::calculateTaxes();
}

启用税费计算后,所有新订阅以及生成的一次性发票都将自动进行税费计算。

为使该功能正常运作,客户的账单详情(如客户姓名、地址和税务标识符)需要同步至 Stripe。你可以使用 Cashier 提供的客户数据同步和税务标识符方法来完成此操作。

日志记录

Cashier 允许你指定在记录 Stripe 致命错误时所使用的日志通道。你可以通过在应用程序的 .env 文件中定义 CASHIER_LOGGER 环境变量来指定日志通道:

CASHIER_LOGGER=stack

向 Stripe 发起 API 调用所产生的异常,将通过应用程序的默认日志通道进行记录。

使用自定义模型

你可以自由扩展 Cashier 内部使用的模型,方法是定义自己的模型并继承相应的 Cashier 模型:

use Laravel\Cashier\Subscription as CashierSubscription;

class Subscription extends CashierSubscription
{
    // ...
}

定义好模型后,你可以通过 Laravel\Cashier\Cashier 类指示 Cashier 使用你的自定义模型。通常情况下,你应该在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中告知 Cashier 关于自定义模型的信息:

use App\Models\Cashier\Subscription;
use App\Models\Cashier\SubscriptionItem;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Cashier::useSubscriptionModel(Subscription::class);
    Cashier::useSubscriptionItemModel(SubscriptionItem::class);
}

快速入门

销售产品

在使用 Stripe Checkout 之前,你应该在 Stripe 仪表板中定义具有固定价格的产品。此外,你还应配置 Cashier 的 Webhook 处理。

在应用程序中提供产品和订阅计费功能可能会令人望而生畏。不过,借助 Cashier 和Stripe Checkout,你可以轻松构建现代、稳健的支付集成。

若要向客户收取非经常性、一次性收费产品的费用,我们将利用 Cashier 将客户引导至 Stripe Checkout,客户将在此提供支付详情并确认购买。一旦通过 Checkout 完成支付,客户将被重定向到你在应用程序中指定的成功 URL:

use Illuminate\Http\Request;

Route::get('/checkout', function (Request $request) {
    $stripePriceId = 'price_deluxe_album';

    $quantity = 1;

    return $request->user()->checkout([$stripePriceId => $quantity], [
        'success_url' => route('checkout-success'),
        'cancel_url' => route('checkout-cancel'),
    ]);
})->name('checkout');

Route::view('/checkout/success', 'checkout.success')->name('checkout-success');
Route::view('/checkout/cancel', 'checkout.cancel')->name('checkout-cancel');

如上例所示,我们将利用 Cashier 提供的 checkout 方法,将客户重定向到 Stripe Checkout,以处理指定的“价格标识符”。在使用 Stripe 时,“价格”指的是针对特定产品设定的价格。

如有必要,checkout 方法会自动在 Stripe 中创建客户,并将该 Stripe 客户记录与应用程序数据库中的相应用户关联起来。完成结账会话后,客户将被重定向到专门的成功或取消页面,你可以在这些页面上向客户展示提示信息。

向 Stripe Checkout 传递元数据

在销售产品时,通常需要通过你自己的应用程序定义的 Cart 和 Order 模型,来跟踪已完成的订单和已购买的产品。当将客户重定向到 Stripe Checkout 以完成购买时,你可能需要提供一个现有的订单标识符,以便在客户被重定向回你的应用程序时,能将完成的购买与相应的订单关联起来。

为此,你可以向 checkout 方法传递一个 metadata 数组。假设当用户开始结账流程时,会在我们的应用程序中创建一个待处理的 Order。请注意,本示例中的 Cart 和 Order 模型仅为示例,并非由 Cashier 提供。你可以根据自身应用程序的需求来实现这些概念:

use App\Models\Cart;
use App\Models\Order;
use Illuminate\Http\Request;

Route::get('/cart/{cart}/checkout', function (Request $request, Cart $cart) {
    $order = Order::create([
        'cart_id' => $cart->id,
        'price_ids' => $cart->price_ids,
        'status' => 'incomplete',
    ]);

    return $request->user()->checkout($order->price_ids, [
        'success_url' => route('checkout-success').'?session_id={CHECKOUT_SESSION_ID}',
        'cancel_url' => route('checkout-cancel'),
        'metadata' => ['order_id' => $order->id],
    ]);
})->name('checkout');

如上例所示,当用户开始结账流程时,我们会将所有购物车 / 订单关联的 Stripe 价格标识符传递给 checkout 方法。当然,你的应用程序需要负责在客户添加商品时,将这些商品与“购物车”或订单关联起来。我们还会通过 metadata 数组将订单 ID 提供给 Stripe Checkout 会话。最后,我们在结账成功路由中添加了 CHECKOUT_SESSION_ID 模板变量。当 Stripe 将客户重定向回你的应用程序时,该模板变量会自动填充 Checkout 会话 ID。

接下来,我们来创建结账成功路由。用户通过 Stripe Checkout 完成购买后,将被重定向到此路由。在此路由中,我们可以获取 Stripe Checkout 会话 ID 以及关联的 Stripe Checkout 实例,从而访问我们提供的数据,并相应地更新客户的订单:

use App\Models\Order;
use Illuminate\Http\Request;
use Laravel\Cashier\Cashier;

Route::get('/checkout/success', function (Request $request) {
    $sessionId = $request->get('session_id');

    if ($sessionId === null) {
        return;
    }

    $session = Cashier::stripe()->checkout->sessions->retrieve($sessionId);

    if ($session->payment_status !== 'paid') {
        return;
    }

    $orderId = $session['metadata']['order_id'] ?? null;

    $order = Order::findOrFail($orderId);

    $order->update(['status' => 'completed']);

    return view('checkout-success', ['order' => $order]);
})->name('checkout-success');

有关 Checkout 会话对象所包含的数据的更多信息,请参阅 Stripe 的文档。

销售订阅服务

在使用 Stripe Checkout 之前,你应该在 Stripe 控制台中定义具有固定价格的产品。此外,你需要配置 Cashier 的 Webhook 处理。

通过你的应用程序提供产品和订阅计费可能会令人望而生畏。但借助 Cashier 和 Stripe Checkout,你可以轻松构建现代化、稳健的支付集成。

为了了解如何使用 Cashier 和 Stripe Checkout 销售订阅服务,让我们考虑一个简单的场景:一项订阅服务,包含基础的月度(price_basic_monthly)和年度(price_basic_yearly)套餐。这两个价格可以在我们 Stripe 控制台中的“Basic”产品(pro_basic)下进行分组。此外,我们的订阅服务还可能提供 Expert 套餐,即 pro_expert。

首先,我们来看看客户如何订阅我们的服务。当然,你可以想象客户可能会在我们应用程序的定价页面上点击 Basic 套餐的“订阅”按钮。该按钮或链接应将用户引导至一个 Laravel 路由,该路由会为他们选择的套餐创建 Stripe Checkout 会话:

use Illuminate\Http\Request;

Route::get('/subscription-checkout', function (Request $request) {
    return $request->user()
        ->newSubscription('default', 'price_basic_monthly')
        ->trialDays(5)
        ->allowPromotionCodes()
        ->checkout([
            'success_url' => route('your-success-route'),
            'cancel_url' => route('your-cancel-route'),
        ]);
});

如上例所示,我们会将客户重定向到 Stripe Checkout 会话,让他们能够订阅我们的 Basic 套餐。在成功结账或取消后,客户将被重定向回我们传递给 checkout 方法的 URL。为了知道他们的订阅何时真正开始(因为某些支付方式需要几秒钟来处理),我们还需要配置 Cashier 的 Webhook 处理。

既然客户可以开始订阅了,我们需要限制应用程序的某些部分,确保只有已订阅的用户才能访问。当然,我们可以随时通过 Cashier 的 Billable 特性提供的 subscribed 方法来确定用户当前的订阅状态:

@if ($user->subscribed())
    <p>You are subscribed.</p>
@endif

我们甚至可以轻松确定用户是否订阅了特定的产品或价格:

@if ($user->subscribedToProduct('pro_basic'))
    <p>You are subscribed to our Basic product.</p>
@endif

@if ($user->subscribedToPrice('price_basic_monthly'))
    <p>You are subscribed to our monthly Basic plan.</p>
@endif

构建订阅中间件

为方便起见,你可能希望创建一个中间件,用于判断传入请求是否来自已订阅的用户。一旦定义了此中间件,你就可以轻松将其分配给路由,以防止未订阅的用户访问该路由:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class Subscribed
{
    /**
     * Handle an incoming request.
     */
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->subscribed()) {
            // Redirect user to billing page and ask them to subscribe...
            return redirect('/billing');
        }

        return $next($request);
    }
}

中间件定义完成后,你可以将其分配给路由:

use App\Http\Middleware\Subscribed;

Route::get('/dashboard', function () {
    // ...
})->middleware([Subscribed::class]);

允许客户管理其计费套餐

当然,客户可能希望将订阅套餐更改为其他产品或“层级”。最简单的实现方式是将客户引导至 Stripe 的客户计费门户,该门户提供了一个托管的用户界面,允许客户下载发票、更新支付方式以及更改订阅套餐。

首先,在应用程序中定义一个链接或按钮,将用户引导至 Laravel 路由,我们将利用该路由发起计费门户会话:

<a href="{{ route('billing') }}">
    Billing
</a>

接下来,我们来定义发起 Stripe 客户计费门户会话并将用户重定向到门户的路由。redirectToBillingPortal 方法接受用户退出门户后应返回的 URL:

use Illuminate\Http\Request;

Route::get('/billing', function (Request $request) {
    return $request->user()->redirectToBillingPortal(route('dashboard'));
})->middleware(['auth'])->name('billing');

只要配置了 Cashier 的 Webhook 处理,Cashier 就会通过检查来自 Stripe 的传入 Webhook,自动保持应用程序中与 Cashier 相关的数据库表同步。因此,例如当用户通过 Stripe 的客户计费门户取消订阅时,Cashier 将接收相应的 Webhook,并在应用程序的数据库中将订阅标记为“已取消”。

客户

检索客户

你可以使用 Cashier::findBillable 方法,通过 Stripe ID 检索客户。该方法将返回一个可计费模型的实例:

use Laravel\Cashier\Cashier;

$user = Cashier::findBillable($stripeId);

创建客户

有时,你可能希望在不开启订阅的情况下创建一个 Stripe 客户。你可以使用 createAsStripeCustomer 方法来实现这一点:

$stripeCustomer = $user->createAsStripeCustomer();

一旦在 Stripe 中创建了客户,您便可以稍后再发起订阅。您可以提供一个可选的 $options 数组,用于传入 Stripe API 支持的客户创建参数:

$stripeCustomer = $user->createAsStripeCustomer($options);

如果您希望为可计费模型返回 Stripe 客户对象,可以使用 asStripeCustomer 方法:

$stripeCustomer = $user->asStripeCustomer();

如果您希望为给定的可计费模型检索 Stripe 客户对象,但不确定该可计费模型在 Stripe 中是否已是客户,可以使用 createOrGetStripeCustomer 方法。如果该模型在 Stripe 中尚不存在对应的客户,此方法将在 Stripe 中创建一个新客户:

$stripeCustomer = $user->createOrGetStripeCustomer();

更新客户

有时,您可能希望使用附加信息直接更新 Stripe 客户。您可以通过 updateStripeCustomer 方法来实现此目的。该方法接受一个包含 Stripe API 支持的客户更新选项的数组:

$stripeCustomer = $user->updateStripeCustomer($options);

余额

Stripe 允许您对客户的“余额”进行入账或出账操作。之后,这笔余额将计入新发票或从新发票中扣减。若要查看客户的总余额,您可以使用可计费模型上提供的 balance 方法。balance 方法将以客户所在货币返回余额的格式化字符串表示形式:

$balance = $user->balance();

若要对客户余额进行入账操作,您可以向 creditBalance 方法提供一个值。如有需要,您还可以提供一段描述:

$user->creditBalance(500, 'Premium customer top-up.');

向 debitBalance 方法提供值将对客户余额进行出账操作:

$user->debitBalance(300, 'Bad usage penalty.');

applyBalance 方法将为该客户创建新的余额交易记录。您可以使用 balanceTransactions 方法检索这些交易记录,这对于为客户提供可查看的入账和出账日志非常有用:

// Retrieve all transactions...
$transactions = $user->balanceTransactions();

foreach ($transactions as $transaction) {
    // Transaction amount...
    $amount = $transaction->amount(); // $2.31

    // Retrieve the related invoice when available...
    $invoice = $transaction->invoice();
}

税号

Cashier 提供了一种管理客户税号的简便方法。例如,可以使用 taxIds 方法,以集合形式检索分配给客户的所有 税号:

$taxIds = $user->taxIds();

您也可以通过标识符检索某个客户的具体税号:

$taxId = $user->findTaxId('txi_belgium');

您可以向 createTaxId 方法提供有效的类型和值,以创建新的税号:

$taxId = $user->createTaxId('eu_vat', 'BE0123456789');

createTaxId 方法会立即将增值税(VAT)ID 添加到客户账户中。增值税 ID 的验证同样由 Stripe 完成;不过,这是一个异步过程。您可以通过订阅 customer.tax_id.updated Webhook 事件,并检查增值税 ID 的 verification 参数,来接收验证更新通知。有关处理 Webhook 的更多信息,请参阅定义 Webhook 处理程序的文档。

您可以使用 deleteTaxId 方法删除税号:

$user->deleteTaxId('txi_belgium');

与客户数据同步到 Stripe

通常情况下,当您的应用程序用户更新其姓名、电子邮箱地址或 Stripe 同样存储的其他信息时,您应将这些更新告知 Stripe。通过这种方式,Stripe 中保存的信息副本将与您应用程序中的信息保持一致。

为了实现自动化,您可以在可计费模型上定义一个事件监听器,使其响应模型的 updated 事件。然后,在该事件监听器中,您可以调用模型的 syncStripeCustomerDetails 方法:

use App\Models\User;
use function Illuminate\Events\queueable;

/**
 * The "booted" method of the model.
 */
protected static function booted(): void
{
    static::updated(queueable(function (User $customer) {
        if ($customer->hasStripeId()) {
            $customer->syncStripeCustomerDetails();
        }
    }));
}

现在,每当您的客户模型被更新时,其信息都会与 Stripe 同步。为了方便起见,Cashier 会在首次创建客户时,自动将您的客户信息同步到 Stripe。

您可以通过重写 Cashier 提供的各种方法,自定义用于向 Stripe 同步客户信息的列。例如,您可以重写 stripeName 方法,以自定义 Cashier 将客户信息同步到 Stripe 时,应作为客户“姓名”处理的属性:

/**
 * Get the customer name that should be synced to Stripe.
 */
public function stripeName(): string|null
{
    return $this->company_name;
}

同样,您也可以重写 stripeEmail、stripePhone(最多 20 个字符)、stripeAddress 和 stripePreferredLocales 方法。在更新 Stripe 客户对象时,这些方法会将信息同步到其对应的客户参数中。如果您希望完全掌控客户信息的同步流程,可以重写 syncStripeCustomerDetails 方法。

计费门户

Stripe 提供了一种便捷的计费门户搭建方式,让您的客户可以管理其订阅和支付方式,并查看计费历史。您可以从控制器或路由中调用可计费模型的 redirectToBillingPortal 方法,将用户重定向到计费门户:

use Illuminate\Http\Request;

Route::get('/billing-portal', function (Request $request) {
    return $request->user()->redirectToBillingPortal();
});

默认情况下,当用户完成订阅管理后,可以通过 Stripe 计费门户内的链接返回到您应用程序的 home 路由。您可以将自定义 URL 作为参数传递给 redirectToBillingPortal 方法,以指定用户应返回的 URL:

use Illuminate\Http\Request;

Route::get('/billing-portal', function (Request $request) {
    return $request->user()->redirectToBillingPortal(route('billing'));
});

如果您希望生成计费门户的 URL,而不生成 HTTP 重定向响应,则可以调用 billingPortalUrl 方法:

$url = $request->user()->billingPortalUrl(route('billing'));

支付方式

存储支付方式

为了通过 Stripe 创建订阅或执行“一次性”扣款,您的应用程序需要安全地收集客户的支付详情。实现这一目标的方法会因您计划存储支付方式以便未来订阅,还是立即处理单次扣款而有所不同,下面我们将分别进行探讨。

Stripe 的 Payment Element(支付组件)可用于支持多种支付方式,例如银行卡、Apple Pay、Google Pay 和 iDEAL。

用于订阅的支付组件

首先,创建一个 Setup Intent,并将其传递给视图:

return view('subscribe', [
    'intent' => $user->createSetupIntent()
]);

使用 Setup Intent 的 client_secret 挂载 Payment Element(支付组件):

<div id="payment-element"></div>
<button id="submit">订阅</button>

<script src="https://js.stripe.com/v3/"></script>
<script>
    const stripe = Stripe('stripe-public-key');

    const elements = stripe.elements({
        clientSecret: '{{ $intent->client_secret }}'
    });

    const paymentElement = elements.create('payment');

    paymentElement.mount('#payment-element');

    document.getElementById('submit').addEventListener('click', async () => {
        const { error } = await stripe.confirmSetup({
            elements,
            confirmParams: {
                return_url: '{{ route("subscription.complete") }}',
            },
        });

        if (error) {
            // 向用户显示“error.message”...
        }
    });
</script>

在 Stripe 重定向到你的 return_url 后,setup_intent ID 将作为查询字符串参数可用。你可以使用该值检索支付方式并创建订阅:

use Illuminate\Http\Request;

Route::get('/subscription/complete', function (Request $request) {
    $setupIntent = $request->user()->findSetupIntent(
        $request->setup_intent
    );

    $paymentMethod = $setupIntent->payment_method;

    $request->user()
        ->newSubscription('default', 'price_xxx')
        ->create($paymentMethod);

    return redirect('/dashboard');
})->name('subscription.complete');

如果你使用 Payment Element 来更新客户的默认支付方式(而非创建订阅),可以将支付方式标识符传递给 updateDefaultPaymentMethod 方法。

用于单次收费的 Payment Element

对于一次性付款,请使用 Cashier 的 pay 方法创建 Payment Intent。通常情况下,你应该将 Payment Intent ID 存储到你应用程序对应的订单中,以便在 Stripe 将客户重定向回你的应用程序后检索该订单。以下示例假设你的应用程序有一个包含 user_id、amount、status 和 stripe_payment_intent_id 列的 Order 模型:

use App\Models\Order;
use Illuminate\Http\Request;

Route::post('/pay', function (Request $request) {
    $amount = 1000;

    $payment = $request->user()->pay($amount);

    $order = Order::create([
        'user_id' => $request->user()->id,
        'amount' => $amount,
        'status' => 'pending',
        'stripe_payment_intent_id' => $payment->id,
    ]);

    return view('checkout', [
        'clientSecret' => $payment->client_secret,
        'order' => $order,
    ]);
});

然后,挂载 Payment Element 并确认付款:

<div id="payment-element"></div>
<button id="submit">立即支付</button>

<script src="https://js.stripe.com/v3/"></script>
<script>
    const stripe = Stripe('stripe-public-key');

    const elements = stripe.elements({
        clientSecret: '{{ $clientSecret }}'
    });

    const paymentElement = elements.create('payment');

    paymentElement.mount('#payment-element');

    document.getElementById('submit').addEventListener('click', async () => {
        const { error } = await stripe.confirmPayment({
            elements,
            confirmParams: {
                return_url: '{{ route("payment.complete") }}',
            },
        });

        if (error) {
            // 向用户显示“error.message”...
        }
    });
</script>

重定向后,你可以使用 payment_intent 查询字符串参数来检索对应的订单和 Payment Intent。在履行订单之前,你应该验证该订单属于已认证的客户,并且该 Payment Intent 属于已认证的客户且已成功:

use App\Models\Order;
use Illuminate\Http\Request;

Route::get('/payment/complete', function (Request $request) {
    $order = Order::where('user_id', $request->user()->id)
        ->where('stripe_payment_intent_id', $request->payment_intent)
        ->firstOrFail();

    $paymentIntent = $request->user()
        ->stripe()
        ->paymentIntents
        ->retrieve($request->payment_intent);

    if ($paymentIntent->customer === $request->user()->stripe_id &&
        $paymentIntent->status === 'succeeded') {
        $order->update(['status' => 'paid']);

        // 履行订单...
    }

    return redirect('/dashboard');
})->name('payment.complete');

检索支付方式

可计费模型实例上的 paymentMethods 方法会返回 Laravel\Cashier\PaymentMethod 实例的集合:

$paymentMethods = $user->paymentMethods();

默认情况下,该方法会返回所有类型的支付方式。若要检索特定类型的支付方式,你可以将 type 作为参数传递给该方法:

$paymentMethods = $user->paymentMethods('sepa_debit');

若要检索客户的默认支付方式,可以使用 defaultPaymentMethod 方法:

$paymentMethod = $user->defaultPaymentMethod();

你可以使用 findPaymentMethod 方法检索附加到可计费模型上的特定支付方式:

$paymentMethod = $user->findPaymentMethod($paymentMethodId);

支付方式存在性

若要判断可计费模型是否在其账户上绑定了默认支付方式,请调用 hasDefaultPaymentMethod 方法:

if ($user->hasDefaultPaymentMethod()) {
    // ...
}

你可以使用 hasPaymentMethod 方法来判断可计费模型的账户上是否至少绑定了一种支付方式:

if ($user->hasPaymentMethod()) {
    // ...
}

该方法会判断可计费模型是否拥有任意支付方式。若要判断模型是否存在特定类型的支付方式,你可以将 type 作为参数传递给该方法:

if ($user->hasPaymentMethod('sepa_debit')) {
    // ...
}

更新默认支付方式

可以使用 updateDefaultPaymentMethod 方法来更新客户的默认支付方式信息。该方法接受一个 Stripe 支付方式标识符,并将新的支付方式指定为默认账单支付方式:

$user->updateDefaultPaymentMethod($paymentMethod);

若要将你的默认支付方式信息与 Stripe 中客户的默认支付方式信息同步,可以使用 updateDefaultPaymentMethodFromStripe 方法:

$user->updateDefaultPaymentMethodFromStripe();

客户账户上的默认支付方式仅能用于开具发票和创建新订阅。由于 Stripe 的限制,它可能无法用于单次收费。

添加支付方式

若要添加新的支付方式,你可以调用可计费模型上的 addPaymentMethod 方法,并传入支付方式标识符:

$user->addPaymentMethod($paymentMethod);

若要了解如何检索支付方式标识符,请查阅支付方式存储相关文档。

删除支付方式

若要删除支付方式,你可以对你希望删除的 Laravel\Cashier\PaymentMethod 实例调用 delete 方法:

$paymentMethod->delete();

deletePaymentMethod 方法将从可计费模型中删除特定的支付方式:

$user->deletePaymentMethod('pm_visa');

deletePaymentMethods 方法将删除可计费模型的所有支付方式信息:

$user->deletePaymentMethods();

默认情况下,该方法会删除所有类型的支付方式。若要删除特定类型的支付方式,你可以将 type 作为参数传递给该方法:

$user->deletePaymentMethods('sepa_debit');

如果用户存在有效订阅,你的应用程序不应允许他们删除其默认支付方式。

订阅

订阅提供了为客户设置周期性付款的方式。由 Cashier 管理的 Stripe 订阅支持多个订阅价格、订阅数量、试用等特性。

创建订阅

要创建订阅,首先获取可计费模型的实例,通常为 App\Models\User 的实例。获取到模型实例后,你可以使用 newSubscription 方法来创建该模型的订阅:

use Illuminate\Http\Request;

Route::post('/user/subscribe', function (Request $request) {
    $request->user()->newSubscription(
        'default', 'price_monthly'
    )->create($request->paymentMethodId);

    // ...
});

传递给 newSubscription 方法的第一个参数应为订阅的内部类型。如果你的应用仅提供单一订阅,你可以将其命名为 default 或 primary。此订阅类型仅供应用内部使用,不面向用户展示。此外,它不应包含空格,且在创建订阅后绝不能更改。第二个参数是用户订阅的具体价格。该值应与 Stripe 中价格的标识符相对应。

接受 Stripe 支付方式标识符或 Stripe PaymentMethod 对象的 create 方法,将启动订阅,同时使用可计费模型的 Stripe 客户 ID 及其他相关账单信息更新你的数据库。

将支付方式标识符直接传递给 create 订阅方法,也会自动将其添加到用户存储的支付方式中。

通过发票邮件收取定期付款

你无需自动收取客户的定期付款,而是可以指示 Stripe 在客户的定期付款到期时,向客户发送发票邮件。随后,客户可在收到发票后手动支付。在通过发票收取定期付款时,客户无需预先提供支付方式:

$user->newSubscription('default', 'price_monthly')->createAndSendInvoice();

客户在订阅被取消前支付发票的时长,由 days_until_due 选项决定。默认情况下,该时长为 30 天;不过,如有需要,你可为该选项指定具体值:

$user->newSubscription('default', 'price_monthly')->createAndSendInvoice([], [
    'days_until_due' => 30
]);

数量

若要在创建订阅时为价格设置特定数量,应在创建订阅前,在订阅构建器上调用 quantity 方法:

$user->newSubscription('default', 'price_monthly')
    ->quantity(5)
    ->create($paymentMethod);

附加详情

若要指定 Stripe 支持的额外客户或订阅选项,可将其作为第二和第三个参数传递给 create 方法:

$user->newSubscription('default', 'price_monthly')->create($paymentMethod, [
    'email' => $email,
], [
    'metadata' => ['note' => 'Some extra information.'],
]);

优惠券

若要在创建订阅时应用优惠券,可使用 withCoupon 方法:

$user->newSubscription('default', 'price_monthly')
    ->withCoupon('code')
    ->create($paymentMethod);

或者,若要应用 Stripe 促销代码,可使用 withPromotionCode 方法:

$user->newSubscription('default', 'price_monthly')
    ->withPromotionCode('promo_code_id')
    ->create($paymentMethod);

给定的促销代码 ID 应为分配给促销代码的 Stripe API ID,而非面向客户的促销代码。若需根据给定的面向客户的促销代码查找促销代码 ID,可使用 findPromotionCode 方法:

// Find a promotion code ID by its customer facing code...
$promotionCode = $user->findPromotionCode('SUMMERSALE');

// Find an active promotion code ID by its customer facing code...
$promotionCode = $user->findActivePromotionCode('SUMMERSALE');

在上面的示例中,返回的 $promotionCode 对象是 Laravel\Cashier\PromotionCode 的实例。该类封装了底层的 Stripe\PromotionCode 对象。你可以通过调用 coupon 方法,获取与促销代码关联的优惠券:

$coupon = $user->findPromotionCode('SUMMERSALE')->coupon();

优惠券实例可让你确定折扣金额,以及优惠券代表的是固定折扣还是按百分比计算的折扣:

if ($coupon->isPercentage()) {
    return $coupon->percentOff().'%'; // 21.5%
} else {
    return $coupon->amountOff(); // $5.99
}

你还可获取当前应用于客户或订阅的折扣:

$discount = $billable->discount();

$discount = $subscription->discount();

返回的 Laravel\Cashier\Discount 实例封装了底层的 Stripe\Discount 对象实例。你可通过调用 coupon 方法,获取与该折扣关联的优惠券:

$coupon = $subscription->discount()->coupon();

若要向客户或订阅应用新的优惠券或促销代码,可通过 applyCoupon 或 applyPromotionCode 方法实现:

$billable->applyCoupon('coupon_id');
$billable->applyPromotionCode('promotion_code_id');

$subscription->applyCoupon('coupon_id');
$subscription->applyPromotionCode('promotion_code_id');

请记住,应使用分配给促销代码的 Stripe API ID,而非面向客户的促销代码。同一时间,客户或订阅只能应用一个优惠券或促销代码。

若想了解更多相关信息,请参阅 Stripe 关于优惠券和促销代码的文档。

添加订阅

若要为已有默认支付方式的客户添加订阅,可在订阅构建器上调用 add 方法:

use App\Models\User;

$user = User::find(1);

$user->newSubscription('default', 'price_monthly')->add();

从 Stripe 控制台创建订阅

你也可以直接从 Stripe 控制台创建订阅。在此情况下,Cashier 会同步新添加的订阅,并将其类型设为 default。若要自定义分配给控制台创建的订阅类型,需定义 Webhook 事件处理程序。

此外,你仅可通过 Stripe 控制台创建一种类型的订阅。如果你的应用提供多种使用不同类型的订阅,则只能通过 Stripe 控制台添加一种类型的订阅。

最后,务必确保应用提供的每种订阅类型,客户只能拥有一个活跃订阅。如果客户有两个 default 订阅,即便两个订阅都会同步到应用的数据库中,Cashier 也只会使用最近添加的那个订阅。

检查订阅状态

一旦客户订阅了你的应用,你可通过多种便捷方法轻松检查其订阅状态。首先,subscribed 方法会在客户拥有活跃订阅时返回 true,即便订阅当前处于试用期内。subscribed 方法将订阅类型作为其第一个参数:

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

subscribed 方法也非常适合作为路由中间件,可用于根据用户的订阅状态筛选对路由和控制器的访问:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureUserIsSubscribed
{
    /**
     * 处理传入的请求。
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        if ($request->user() && ! $request->user()->subscribed('default')) {
            // 该用户并非付费客户……
            return redirect('/billing');
        }

        return $next($request);
    }
}

若要判断用户是否仍处于试用期内,可使用 onTrial 方法。当需要判断是否应向用户显示仍处于试用期的警告时,该方法十分有用:

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

可以使用 subscribedToProduct 方法,根据给定 Stripe 产品的标识符来判断用户是否已订阅某个产品。在 Stripe 中,产品是由价格组成的集合。在本示例中,我们将判断用户的 default 订阅是否正活跃地订阅了应用的“premium”产品。给定的 Stripe 产品标识符应与 Stripe 控制台中您某个产品的标识符一致:

if ($user->subscribedToProduct('prod_premium', 'default')) {
    // ...
}

通过向 subscribedToProduct 方法传入一个数组,您可以判断用户的 default 订阅是否正活跃地订阅了应用的“basic”或“premium”产品:

if ($user->subscribedToProduct(['prod_basic', 'prod_premium'], 'default')) {
    // ...
}

可以使用 subscribedToPrice 方法,判断客户的订阅是否与给定的价格 ID 相对应:

if ($user->subscribedToPrice('price_basic_monthly', 'default')) {
    // ...
}

可以使用 recurring 方法,判断用户当前是否已订阅且不再处于试用期内:

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

如果用户拥有两个相同类型的订阅,subscription 方法将始终返回最近创建的订阅。例如,用户可能拥有两条类型为 default 的订阅记录;不过,其中一条可能是已过期的旧订阅,而另一条则是当前处于活跃状态的订阅。该方法始终返回最近的订阅,旧订阅则保留在数据库中以供历史查询。

已取消的订阅状态

若要判断用户此前曾为活跃订阅者但已取消订阅,可以使用 canceled 方法:

if ($user->subscription('default')->canceled()) {
    // ...
}

您还可以判断用户是否已取消订阅但仍处于“宽限期”,直至订阅完全到期。例如,若用户于 3 月 5 日取消订阅,而该订阅原定于 3 月 10 日到期,则在 3 月 10 日之前用户处于“宽限期”。请注意,在此期间 subscribed 方法仍会返回 true:

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

若要判断用户是否已取消订阅且不再处于“宽限期”,可以使用 ended 方法:

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

未完成与逾期状态

如果在创建订阅后需要用户进行二次支付操作,则该订阅将被标记为 incomplete。订阅状态存储在 Cashier 的 subscriptions 数据库表的 stripe_status 列中。

同样,在更换价格时若需要二次支付操作,订阅将被标记为 past_due。当订阅处于上述任一状态时,在客户确认支付前,订阅不会激活。您可以通过可计费模型或订阅实例上的 hasIncompletePayment 方法,判断订阅是否存在未完成的支付:

if ($user->hasIncompletePayment('default')) {
    // ...
}

if ($user->subscription('default')->hasIncompletePayment()) {
    // ...
}

当订阅存在未完成支付时,您应引导用户前往 Cashier 的支付确认页面,并传入 latestPayment 标识符。您可以使用订阅实例上的 latestPayment 方法来获取该标识符:

<a href="{{ route('cashier.payment', $subscription->latestPayment()->id) }}">
    请确认您的支付。
</a>

如果希望订阅在处于 past_due 或 incomplete 状态时仍被视为活跃状态,可以使用 Cashier 提供的 keepPastDueSubscriptionsActive 和 keepIncompleteSubscriptionsActive 方法。通常,应在 App\Providers\AppServiceProvider 的 register 方法中调用这些方法:

use Laravel\Cashier\Cashier;

/**
 * Register any application services.
 */
public function register(): void
{
    Cashier::keepPastDueSubscriptionsActive();
    Cashier::keepIncompleteSubscriptionsActive();
}

当订阅处于 incomplete 状态时,在确认支付前无法对其进行更改。因此,当订阅处于 incomplete 状态时,调用 swap 和 updateQuantity 方法将抛出异常。

订阅作用域

大多数订阅状态也可作为查询作用域使用,以便您轻松查询处于特定状态的订阅:

// 获取所有活跃订阅……
$subscriptions = Subscription::query()->active()->get();

// 获取某用户的所有已取消订阅……
$subscriptions = $user->subscriptions()->canceled()->get();

以下为可用作用域的完整列表:

Subscription::query()->active();
Subscription::query()->canceled();
Subscription::query()->ended();
Subscription::query()->incomplete();
Subscription::query()->notCanceled();
Subscription::query()->notOnGracePeriod();
Subscription::query()->notOnTrial();
Subscription::query()->onGracePeriod();
Subscription::query()->onTrial();
Subscription::query()->pastDue();
Subscription::query()->recurring();

更改价格

客户订阅您的应用后,偶尔可能需要更改到新的订阅价格。要将客户切换到新价格,请将 Stripe 价格的标识符传入 swap 方法。切换价格时,假定用户希望在此前已取消订阅的情况下重新激活订阅。给定的价格标识符应与 Stripe 控制台中可用的 Stripe 价格标识符一致:

use App\Models\User;

$user = App\Models\User::find(1);

$user->subscription('default')->swap('price_yearly');

如果客户处于试用期,试用期将得以保留。此外,若订阅存在“数量”,该数量也将被保留。

若希望在切换价格的同时取消客户当前处于的试用期,可以调用 skipTrial 方法:

$user->subscription('default')
    ->skipTrial()
    ->swap('price_yearly');

若希望在切换价格的同时立即向客户开具账单,而不是等待下一个账单周期,可以使用 swapAndInvoice 方法:

$user = User::find(1);

$user->subscription('default')->swapAndInvoice('price_yearly');

按比例计算

默认情况下,Stripe 会在价格之间切换时对费用进行按比例计算。可以使用 noProrate 方法在不进行按比例计算的情况下更新订阅价格:

$user->subscription('default')->noProrate()->swap('price_yearly');

有关订阅按比例计算的更多信息,请参阅Stripe 文档。

在 swapAndInvoice 方法之前调用 noProrate 方法对按比例计算不会有任何影响。账单始终会被开具。

订阅数量

有时订阅会受到“数量”的影响。例如,项目管理应用可能按每个项目每月 10 美元收费。您可以使用 incrementQuantity 和 decrementQuantity 方法,轻松增加或减少订阅数量:

use App\Models\User;

$user = User::find(1);

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

// 在订阅当前数量上增加 5……
$user->subscription('default')->incrementQuantity(5);

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

// 从订阅当前数量中减去 5……
$user->subscription('default')->decrementQuantity(5);

此外,您可以使用 updateQuantity 方法设置指定数量:

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

可以使用 noProrate 方法在不进行按比例计算的情况下更新订阅数量:

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

有关订阅数量的更多信息,请参阅Stripe 文档。

多产品订阅的数量

如果您的订阅是包含多种产品的订阅,则应将您希望对其数量进行增加或减少的价格 ID 作为第二个参数传递给 increase / decrement 方法:

$user->subscription('default')->incrementQuantity(1, 'price_chat');

包含多种产品的订阅

包含多种产品的订阅允许您将多种计费产品分配给单个订阅。例如,假设您正在构建一个客户服务“帮助台”应用程序,该应用程序的基础订阅价格为每月 10 美元,但提供一个实时聊天附加产品,每月额外收费 15 美元。包含多种产品的订阅相关信息存储在 Cashier 的 subscription_items 数据库表中。

您可以通过将价格数组作为第二个参数传递给 newSubscription 方法,为给定订阅指定多种产品:

use Illuminate\Http\Request;

Route::post('/user/subscribe', function (Request $request) {
    $request->user()->newSubscription('default', [
        'price_monthly',
        'price_chat',
    ])->create($request->paymentMethodId);

    // ...
});

在上述示例中,客户将有两个价格附加到其 default 订阅上。这两个价格将按照各自的计费周期进行收费。如有必要,您可以使用 quantity 方法为每个价格指定具体数量:

$user = User::find(1);

$user->newSubscription('default', ['price_monthly', 'price_chat'])
    ->quantity(5, 'price_chat')
    ->create($paymentMethod);

如果您想为现有订阅添加另一个价格,可以调用订阅的 addPrice 方法:

$user = User::find(1);

$user->subscription('default')->addPrice('price_chat');

上述示例将添加新价格,客户将在下一个计费周期为此付费。如果您想立即向客户收费,可以使用 addPriceAndInvoice 方法:

$user->subscription('default')->addPriceAndInvoice('price_chat');

如果您想添加一个具有特定数量的价格,可以将数量作为 addPrice 或 addPriceAndInvoice 方法的第二个参数传入:

$user = User::find(1);

$user->subscription('default')->addPrice('price_chat', 5);

您可以使用 removePrice 方法从订阅中移除价格:

$user->subscription('default')->removePrice('price_chat');

您不能移除订阅上的最后一个价格。此时,您只需取消该订阅即可。

更换价格

您也可以更改附加在包含多种产品的订阅上的价格。例如,假设某位客户有一个带有 price_chat 附加产品的 price_basic 订阅,并且您想将该客户从 price_basic 升级到 price_pro 价格:

use App\Models\User;

$user = User::find(1);

$user->subscription('default')->swap(['price_pro', 'price_chat']);

执行上述示例时,包含 price_basic 的底层订阅项会被删除,而包含 price_chat 的订阅项会被保留。此外,还会为 price_pro 创建一个新的订阅项。

您也可以通过将键 / 值对数组传递给 swap 方法来指定订阅项选项。例如,您可能需要指定订阅价格的数量:

$user = User::find(1);

$user->subscription('default')->swap([
    'price_pro' => ['quantity' => 5],
    'price_chat'
]);

如果您想更换订阅中的单个价格,可以在订阅项本身上使用 swap 方法来实现。如果您想保留订阅中其他价格的所有现有元数据,这种方法尤其适用:

$user = User::find(1);

$user->subscription('default')
    ->findItemOrFail('price_basic')
    ->swap('price_pro');

按比例计费

默认情况下,当从包含多种产品的订阅中添加或移除价格时,Stripe 会按比例计算费用。如果您想进行价格调整且不进行按比例计费,应该在价格操作中链式调用 noProrate 方法:

$user->subscription('default')->noProrate()->removePrice('price_chat');

数量

如果您想更新单个订阅价格的数量,可以通过现有数量方法来实现,将价格 ID 作为额外参数传递给该方法:

$user = User::find(1);

$user->subscription('default')->incrementQuantity(5, 'price_chat');

$user->subscription('default')->decrementQuantity(3, 'price_chat');

$user->subscription('default')->updateQuantity(10, 'price_chat');

当订阅包含多个价格时,Subscription 模型上的 stripe_price 和 quantity 属性将为 null。要访问单个价格属性,应使用 Subscription 模型上可用的 items 关系。

订阅项

当订阅包含多个价格时,数据库的 subscription_items 表中将存储多个订阅“项”。您可以通过订阅上的 items 关系来访问这些项:

use App\Models\User;

$user = User::find(1);

$subscriptionItem = $user->subscription('default')->items->first();

// Retrieve the Stripe price and quantity for a specific item...
$stripePrice = $subscriptionItem->stripe_price;
$quantity = $subscriptionItem->quantity;

您也可以使用 findItemOrFail 方法检索特定价格:

$user = User::find(1);

$subscriptionItem = $user->subscription('default')->findItemOrFail('price_chat');

多个订阅

Stripe 允许您的客户同时拥有多个订阅。例如,您可能经营一家健身房,提供游泳订阅和举重订阅,每个订阅的定价可能不同。当然,客户应该能够订阅其中一项或两项计划。

当您的应用程序创建订阅时,可以向 newSubscription 方法提供订阅的类型。该类型可以是表示用户正在发起的订阅类型的任意字符串:

use Illuminate\Http\Request;

Route::post('/swimming/subscribe', function (Request $request) {
    $request->user()->newSubscription('swimming')
        ->price('price_swimming_monthly')
        ->create($request->paymentMethodId);

    // ...
});

在此示例中,我们为客户发起了每月游泳订阅。然而,他们可能希望在稍后更换为年度订阅。当调整客户的订阅时,我们只需更换 swimming 订阅上的价格即可:

$user->subscription('swimming')->swap('price_swimming_yearly');

当然,您也可以完全取消该订阅:

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

基于用量的计费

基于用量的计费允许您根据客户在计费周期内的产品使用情况向其收费。例如,您可以根据客户每月发送的短信或电子邮件数量向他们收费。

要开始使用基于用量的计费,您首先需要在 Stripe 控制面板中创建一个新产品,该产品需具有基于用量的计费模型和一个计量器。创建计量器后,存储关联的事件名称和计量器 ID,您需要这些信息来报告和检索用量。然后,使用 meteredPrice 方法将计量价格 ID 添加到客户订阅中:

use Illuminate\Http\Request;

Route::post('/user/subscribe', function (Request $request) {
    $request->user()->newSubscription('default')
        ->meteredPrice('price_metered')
        ->create($request->paymentMethodId);

    // ...
});

您也可以通过 Stripe Checkout 启动计量订阅:

$checkout = Auth::user()
    ->newSubscription('default', [])
    ->meteredPrice('price_metered')
    ->checkout();

return view('your-checkout-view', [
    'checkout' => $checkout,
]);

用量报告

当客户使用您的应用程序时,您需要向 Stripe 报告其用量,以便准确计费。要报告计量事件的用量,可以在 Billable 模型上使用 reportMeterEvent 方法:

$user = User::find(1);

$user->reportMeterEvent('emails-sent');

默认情况下,计费周期中会添加 1 的“使用量”。此外,你也可以传递特定的“使用量”数值,用于增加客户在该计费周期的使用量:

$user = User::find(1);

$user->reportMeterEvent('emails-sent', quantity: 15);

若要获取某计量的客户事件汇总,可以使用 Billable 实例的 meterEventSummaries 方法:

$user = User::find(1);

$meterUsage = $user->meterEventSummaries($meterId);

$meterUsage->first()->aggregated_value; // 10

有关计量事件汇总的更多信息,请参阅 Stripe 的计量事件汇总对象文档。

若要列出所有计量,可以使用 Billable 实例的 meters 方法:

$user = User::find(1);

$user->meters();

订阅税费

无需手动计算税率,你可以使用 Stripe Tax 自动计算税费。

若要指定用户在订阅上支付的税率,应在可计费模型中实现 taxRates 方法,并返回一个包含 Stripe 税率 ID 的数组。你可以在Stripe 控制面板中定义这些税率:

/**
 * The tax rates that should apply to the customer's subscriptions.
 *
 * @return array<int, string>
 */
public function taxRates(): array
{
    return ['txr_id'];
}

taxRates 方法可让你按客户逐一应用税率,这对于覆盖多个国家和税率的用户群体非常有用。

若提供的订阅包含多个产品,你可以通过在可计费模型中实现 priceTaxRates 方法,为每个价格定义不同的税率:

/**
 * The tax rates that should apply to the customer's subscriptions.
 *
 * @return array<string, array<int, string>>
 */
public function priceTaxRates(): array
{
    return [
        'price_monthly' => ['txr_id'],
    ];
}

taxRates 方法仅适用于订阅扣费。若使用 Cashier 进行“一次性”扣费,则需在扣费时手动指定税率。

同步税率

修改 taxRates 方法返回的硬编码税率 ID 后,用户现有订阅的税费设置将保持不变。若要使用新的 taxRates 值更新现有订阅的税费,应调用用户订阅实例的 syncTaxRates 方法:

$user->subscription('default')->syncTaxRates();

这也会同步包含多个产品的订阅中各项目的税率。若你的应用提供多产品订阅,应确保可计费模型实现了上文讨论的 priceTaxRates 方法。

税费豁免

Cashier 还提供 isNotTaxExempt、isTaxExempt 和 reverseChargeApplies 方法,用于判断客户是否免征税费。这些方法会调用 Stripe API 来确定客户的税费豁免状态:

use App\Models\User;

$user = User::find(1);

$user->isTaxExempt();
$user->isNotTaxExempt();
$user->reverseChargeApplies();

这些方法在任意 Laravel\Cashier\Invoice 对象上也可用。不过,在 Invoice 对象上调用时,这些方法会返回发票创建时的豁免状态。

订阅锚点日期

默认情况下,计费周期锚点为订阅创建日期;若使用试用期,则锚点为试用期结束日期。若要修改计费锚点日期,可以使用 anchorBillingCycleOn 方法:

use Illuminate\Http\Request;

Route::post('/user/subscribe', function (Request $request) {
    $anchor = Carbon::parse('first day of next month');

    $request->user()->newSubscription('default', 'price_monthly')
        ->anchorBillingCycleOn($anchor->startOfDay())
        ->create($request->paymentMethodId);

    // ...
});

有关管理订阅计费周期的更多信息,请参阅Stripe 计费周期文档。

取消订阅

若要取消订阅,请调用用户订阅的 cancel 方法:

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

订阅被取消后,Cashier 会自动在 subscriptions 数据库表中设置 ends_at 列。该列用于确定 subscribed 方法应何时开始返回 false。

例如,若客户在 3 月 1 日取消订阅,但订阅原定于 3 月 5 日结束,subscribed 方法将一直返回 true 直至 3 月 5 日。这是因为用户通常可继续使用应用,直至其计费周期结束。

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

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

若希望立即取消订阅,请调用用户订阅的 cancelNow 方法:

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

若希望立即取消订阅,并对剩余未开票的计量使用或新的/待处理的按比例计费发票项进行开票,请调用用户订阅的 cancelNowAndInvoice 方法:

$user->subscription('default')->cancelNowAndInvoice();

你还可以选择在特定时间取消订阅:

$user->subscription('default')->cancelAt(
    now()->plus(days: 10)
);

最后,删除关联用户模型前,应始终先取消该用户的订阅:

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

$user->delete();

恢复订阅

若客户已取消订阅且你希望恢复订阅,可调用订阅的 resume 方法。客户必须仍处于“宽限期”内才能恢复订阅:

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

若客户取消订阅后,在订阅完全到期前恢复该订阅,系统不会立即向其收费。相反,订阅将被重新激活,并按原计费周期进行扣费。

订阅试用期

预先收集支付方式

若希望向客户提供试用期,同时仍预先收集支付方式信息,应在创建订阅时使用 trialDays 方法:

use Illuminate\Http\Request;

Route::post('/user/subscribe', function (Request $request) {
    $request->user()->newSubscription('default', 'price_monthly')
        ->trialDays(10)
        ->create($request->paymentMethodId);

    // ...
});

该方法会在数据库的订阅记录中设置试用期结束日期,并指示 Stripe 在该日期之后才开始向客户收费。使用 trialDays 方法时,Cashier 会覆盖 Stripe 中为该价格配置的默认试用期。

若客户未在试用期结束日期前取消订阅,试用期到期后将立即被扣费,因此务必通知用户其试用期结束日期。

trialUntil 方法允许你传入一个 DateTime 实例,用于指定试用期的结束时间:

use Illuminate\Support\Carbon;

$user->newSubscription('default', 'price_monthly')
    ->trialUntil(Carbon::now()->plus(days: 10))
    ->create($paymentMethod);

你可以使用用户实例的 onTrial 方法或订阅实例的 onTrial 方法,判断用户是否处于试用期。以下两个示例效果相同:

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

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

可以使用 endTrial 方法立即结束订阅试用期:

$user->subscription('default')->endTrial();

若要判断现有试用期是否已到期,可以使用 hasExpiredTrial 方法:

if ($user->hasExpiredTrial('default')) {
    // ...
}

if ($user->subscription('default')->hasExpiredTrial()) {
    // ...
}

在 Stripe / Cashier 中定义试用天数

你可以选择在 Stripe 控制台中定义价格的试用天数,或者始终使用 Cashier 明确传入试用天数。如果你选择在 Stripe 中定义价格的试用天数,需注意,新订阅(包括过去曾有订阅的客户的新订阅)将始终获得试用期,除非你显式调用 skipTrial() 方法。

无需提前收集支付方式

如果你想在不提前收集用户支付方式信息的情况下提供试用期,可以在用户记录上将 trial_ends_at 列设置为所需的试用结束日期。这通常在用户注册期间完成:

use App\Models\User;

$user = User::create([
    // ...
    'trial_ends_at' => now()->plus(days: 10),
]);

请务必在可计费模型的类定义中,为 trial_ends_at 属性添加日期类型转换。

Cashier 将这种类型的试用称为“通用试用(generic trial)”,因为它不与任何现有订阅关联。当当前日期未超过 trial_ends_at 的值时,可计费模型实例上的 onTrial 方法将返回 true:

if ($user->onTrial()) {
    // 用户处于试用期内……
}

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

$user = User::find(1);

$user->newSubscription('default', 'price_monthly')->create($paymentMethod);

要获取用户的试用结束日期,可以使用 trialEndsAt 方法。如果用户处于试用期,该方法将返回一个 Carbon 日期实例;否则返回 null。如果你想获取默认订阅之外的特定订阅的试用结束日期,也可以传入一个可选的订阅类型参数:

if ($user->onTrial()) {
    $trialEndsAt = $user->trialEndsAt('main');
}

如果你希望明确了解用户正处于“通用”试用期且尚未创建实际订阅,也可以使用 onGenericTrial 方法:

if ($user->onGenericTrial()) {
    // 用户处于“通用”试用期内……
}

延长试用期

extendTrial 方法允许你在订阅创建后延长其试用期。即使试用已过期且客户已在为该订阅付费,你仍可以为其提供延长试用期。试用期内的时间将从客户的下一张发票中扣除:

use App\Models\User;

$subscription = User::find(1)->subscription('default');

// 在 7 天后结束试用……
$subscription->extendTrial(
    now()->plus(days: 7)
);

// 为试用期额外增加 5 天……
$subscription->extendTrial(
    $subscription->trial_ends_at->plus(days: 5)
);

处理 Stripe Webhook

你可以使用Stripe CLI 来帮助在本地开发过程中测试 Webhook。

Stripe 可以通过 Webhook 向你的应用通知各类事件。默认情况下,Cashier 服务提供者会自动注册一条指向 Cashier Webhook 控制器的路由,该控制器将处理所有传入的 Webhook 请求。

默认情况下,Cashier 的 Webhook 控制器会自动处理以下事项:取消因失败扣款(按你的 Stripe 设置定义)而需取消的订阅、客户更新、客户删除、订阅更新以及支付方式变更;不过,正如我们接下来将了解到的,你可以扩展该控制器来处理任意 Stripe Webhook 事件。

为确保你的应用能够处理 Stripe Webhook,请务必在 Stripe 控制面板中配置 Webhook URL。默认情况下,Cashier 的 Webhook 控制器响应 /stripe/webhook URL 路径。你应该在 Stripe 控制面板中启用的全部 Webhook 列表如下:

  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • customer.updated
  • customer.deleted
  • payment_method.automatically_updated
  • invoice.payment_action_required
  • invoice.payment_succeeded

为方便起见,Cashier 提供了 cashier:webhook Artisan 命令。该命令会在 Stripe 中创建一个 Webhook,监听 Cashier 所需的全部事件:

php artisan cashier:webhook

默认情况下,创建的 Webhook 将指向 APP_URL 环境变量定义的 URL,以及 Cashier 自带的 cashier.webhook 路由。如果你想使用不同的 URL,可以在执行命令时提供 --url 选项:

php artisan cashier:webhook --url "https://example.com/stripe/webhook"

所创建的 Webhook 将使用与 Cashier 相同的 Stripe API 版本,该版本由你安装的 stripe/stripe-php 版本决定。如果你想使用其他 Stripe 版本,可以提供 --api-version 选项:

php artisan cashier:webhook --api-version="2019-12-03"

创建完成后,该 Webhook 将立即生效。如果你想创建 Webhook 但暂时不启用,可以在执行命令时提供 --disabled 选项:

php artisan cashier:webhook --disabled

请务必使用 Cashier 自带的 Webhook 签名验证中间件,保护传入的 Stripe Webhook 请求。

Webhook 与 CSRF 保护

由于 Stripe Webhook 需要绕过 Laravel 的CSRF 保护,你应该确保 Laravel 不会尝试对传入的 Stripe Webhook 验证 CSRF token。为此,应在应用的 bootstrap/app.php 文件中,将 stripe/* 从 CSRF 保护中排除:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->preventRequestForgery(except: [
        'stripe/*',
    ]);
})

定义 Webhook 事件处理器

Cashier 会自动处理因失败扣款而引发的订阅取消,以及其他常见的 Stripe Webhook 事件。不过,如果你还有其他需要处理的 Webhook 事件,可以通过监听 Cashier 派发的以下事件来实现:

  • Laravel\Cashier\Events\WebhookReceived
  • Laravel\Cashier\Events\WebhookHandled

这两个事件都包含 Stripe Webhook 的完整负载。例如,如果你想处理 invoice.payment_succeeded Webhook,可以注册一个监听器来处理该事件:

<?php

namespace App\Listeners;

use Laravel\Cashier\Events\WebhookReceived;

class StripeEventListener
{
    /**
     * 处理接收到的 Stripe Webhook。
     */
    public function handle(WebhookReceived $event): void
    {
        if ($event->payload['type'] === 'invoice.payment_succeeded') {
            // 处理传入的事件……
        }
    }
}

验证 Webhook 签名

为了保障 Webhook 安全,你可以使用Stripe 的 Webhook 签名。为方便起见,Cashier 自带了一个中间件,用于验证传入的 Stripe Webhook 请求是否有效。

要启用 Webhook 验证,请确保在应用的 .env 文件中设置了 STRIPE_WEBHOOK_SECRET 环境变量。Webhook 的 secret 可在 Stripe 账户控制面板中获取。

单次扣款

简单扣款

如果你想使用支付方式标识符对客户进行一次性扣款,可以在可计费模型实例上使用 charge 方法。如果你需要在处理一次性扣款前从客户处收集支付详情,请参阅“单次扣款的 Payment Element”文档:

use Illuminate\Http\Request;

Route::post('/purchase', function (Request $request) {
    $payment = $request->user()->charge(
        100, $request->paymentMethodId
    );

    // ...
});

charge 方法接受数组作为其第三个参数,允许你向底层 Stripe Payment Intent 创建过程传入任意选项。有关创建 Payment Intent 时可用选项的更多信息,请参阅Stripe 文档:

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

你也可以在没有关联客户或用户的情况下使用 charge 方法。要实现这一点,请在你的应用程序计费模型的新实例上调用 charge 方法:

use App\Models\User;

$payment = (new User)->charge(100, $paymentMethod);

如果扣款失败,charge 方法将抛出异常。如果扣款成功,该方法将返回 Laravel\Cashier\Payment 的实例:

try {
    $payment = $user->charge(100, $paymentMethod);
} catch (Exception $e) {
    // ...
}

charge 方法接收的支付金额需以你应用程序所用货币的最小单位表示。例如,如果客户使用美元支付,金额应以美分指定。

通过发票扣款

有时你可能需要进行一次性扣款,并向客户提供 PDF 发票。invoicePrice 方法就能实现这一功能。例如,让我们向客户开具五件新衬衫的发票:

$user->invoicePrice('price_tshirt', 5);

发票将立即从用户的默认支付方式中扣款。invoicePrice 方法的第三个参数也可以接收一个数组,该数组包含发票项目的计费选项。该方法接收的第四个参数同样是一个数组,应包含发票本身的计费选项:

$user->invoicePrice('price_tshirt', 5, [
    'discounts' => [
        ['coupon' => 'SUMMER21SALE']
    ],
], [
    'default_tax_rates' => ['txr_id'],
]);

与 invoicePrice 类似,你可以使用 tabPrice 方法,通过将多个项目(每张发票最多 250 个项目)添加到客户的“账单”中,然后向客户开具发票,来创建一次性扣款。例如,我们可以向客户开具五件衬衫和两个杯子的发票:

$user->tabPrice('price_tshirt', 5);
$user->tabPrice('price_mug', 2);
$user->invoice();

或者,你可以使用 invoiceFor 方法,从客户的默认支付方式中进行“一次性”扣款:

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

虽然 invoiceFor 方法可供使用,但建议你使用带有预定义价格的 invoicePrice 和 tabPrice 方法。这样一来,你可以在 Stripe 控制面板中按产品维度查看更优质的销售分析和数据。

invoice、invoicePrice 和 invoiceFor 方法会创建 Stripe 发票,该发票会自动重试失败的扣款操作。如果你不希望发票重试失败的扣款,需要在首次扣款失败后,通过 Stripe API 将其关闭。

创建支付意图

你可以通过对计费模型实例调用 pay 方法,来创建新的 Stripe 支付意图。调用此方法会创建一个支付意图,并将其封装在 Laravel\Cashier\Payment 实例中:

use Illuminate\Http\Request;

Route::post('/pay', function (Request $request) {
    $payment = $request->user()->pay(
        $request->get('amount')
    );

    return $payment->client_secret;
});

创建支付意图后,你可以将客户端密钥返回给应用程序的前端,以便用户在浏览器中完成支付。要了解更多关于使用 Stripe 支付意图构建完整支付流程的内容,请查阅 Stripe 文档。

使用 pay 方法时,Stripe 控制面板中已启用的默认支付方式将对客户可用。或者,如果你只想允许使用某些特定的支付方式,可以使用 payWith 方法:

use Illuminate\Http\Request;

Route::post('/pay', function (Request $request) {
    $payment = $request->user()->payWith(
        $request->get('amount'), ['card', 'bancontact']
    );

    return $payment->client_secret;
});

pay 和 payWith 方法接收的支付金额需以你应用程序所用货币的最小单位表示。例如,如果客户使用美元支付,金额应以美分指定。

退款

如果需要对 Stripe 支付进行退款,可以使用 refund 方法。该方法的第一个参数接收 Stripe 支付意图 ID:

$payment = $user->charge(100, $paymentMethodId);

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

发票

获取发票

你可以使用 invoices 方法轻松获取计费模型的发票数组。invoices 方法会返回 Laravel\Cashier\Invoice 实例的集合:

$invoices = $user->invoices();

如果你想在结果中包含待处理的发票,可以使用 invoicesIncludingPending 方法:

$invoices = $user->invoicesIncludingPending();

你可以使用 findInvoice 方法,通过发票 ID 检索特定发票:

$invoice = $user->findInvoice($invoiceId);

显示发票信息

在列出客户发票时,可以使用发票的方法来显示相关发票信息。例如,你可能希望在表格中列出所有发票,让用户能轻松下载任意一张发票:

<table>
    @foreach ($invoices as $invoice)
        <tr>
            <td>{{ $invoice->date()->toFormattedDateString() }}</td>
            <td>{{ $invoice->total() }}</td>
            <td><a href="/user/invoice/{{ $invoice->id }}">下载</a></td>
        </tr>
    @endforeach
</table>

即将生成的发票

要获取客户的即将生成的发票,可以使用 upcomingInvoice 方法:

$invoice = $user->upcomingInvoice();

同样,如果客户有多个订阅,你还可以获取特定订阅的即将生成的发票:

$invoice = $user->subscription('default')->upcomingInvoice();

预览订阅发票

使用 previewInvoice 方法,你可以在修改价格前预览发票。这将使你能够了解在做出特定价格调整后,客户发票的具体样式:

$invoice = $user->subscription('default')->previewInvoice('price_yearly');

你可以向 previewInvoice 方法传入一个价格数组,以预览包含多个新价格的发票:

$invoice = $user->subscription('default')->previewInvoice(['price_yearly', 'price_metered']);

生成发票 PDF

在生成发票 PDF 之前,你应该使用 Composer 安装 Dompdf 库,这是 Cashier 默认的发票渲染器:

composer require dompdf/dompdf

在路由或控制器中,你可以使用 downloadInvoice 方法生成指定发票的 PDF 下载文件。该方法会自动生成下载发票所需的正确 HTTP 响应:

use Illuminate\Http\Request;

Route::get('/user/invoice/{invoice}', function (Request $request, string $invoiceId) {
    return $request->user()->downloadInvoice($invoiceId);
});

默认情况下,发票上的所有数据均来源于存储在 Stripe 中的客户和发票数据。文件名基于你的 app.name 配置值。不过,你可以通过向 downloadInvoice 方法的第二个参数传入数组,来自定义部分数据。该数组允许你自定义公司、产品详情等信息:

return $request->user()->downloadInvoice($invoiceId, [
    'vendor' => 'Your Company',
    'product' => 'Your Product',
    'street' => 'Main Str. 1',
    'location' => '2000 Antwerp, Belgium',
    'phone' => '+32 499 00 00 00',
    'email' => '[email protected]',
    'url' => 'https://example.com',
    'vendorVat' => 'BE123456789',
]);

downloadInvoice 方法还支持通过第三个参数自定义文件名,该文件名会自动添加 .pdf 后缀:

return $request->user()->downloadInvoice($invoiceId, [], 'my-invoice');

自定义发票渲染器

Cashier 也支持使用自定义发票渲染器。默认情况下,Cashier 使用 DompdfInvoiceRenderer 实现,该实现利用 dompdf PHP 库来生成 Cashier 的发票。不过,你可以通过实现 Laravel\Cashier\Contracts\InvoiceRenderer 接口,使用任意你喜欢的渲染器。例如,你可能希望通过调用第三方 PDF 渲染服务的 API 来渲染发票 PDF:

use Illuminate\Support\Facades\Http;
use Laravel\Cashier\Contracts\InvoiceRenderer;
use Laravel\Cashier\Invoice;

class ApiInvoiceRenderer implements InvoiceRenderer
{
    /**
     * 渲染给定的发票并返回原始 PDF 字节。
     */
    public function render(Invoice $invoice, array $data = [], array $options = []): string
    {
        $html = $invoice->view($data)->render();

        return Http::get('https://example.com/html-to-pdf', ['html' => $html])->get()->body();
    }
}

实现发票渲染器契约后,应更新应用 config/cashier.php 配置文件中的 cashier.invoices.renderer 配置值。该配置值应设为你自定义渲染器实现的类名。

结账

Cashier Stripe 也支持 Stripe Checkout。Stripe Checkout 提供预构建的托管支付页面,免去了实现自定义支付页面的麻烦。

以下文档介绍了如何将 Stripe Checkout 与 Cashier 结合使用。要进一步了解 Stripe Checkout,建议同时查阅 Stripe 官方的 Checkout 文档。

产品结账

可通过可计费模型上的 checkout 方法,对已在 Stripe 控制台中创建的现有产品执行结账。checkout 方法会启动一个新的 Stripe Checkout 会话。默认情况下,需传入 Stripe Price ID:

use Illuminate\Http\Request;

Route::get('/product-checkout', function (Request $request) {
    return $request->user()->checkout('price_tshirt');
});

如有需要,也可指定产品数量:

use Illuminate\Http\Request;

Route::get('/product-checkout', function (Request $request) {
    return $request->user()->checkout(['price_tshirt' => 15]);
});

当客户访问此路由时,会被重定向到 Stripe 的 Checkout 页面。默认情况下,用户成功完成或取消购买后,会被重定向到 home 路由位置,但可通过 success_url 和 cancel_url 选项指定自定义回调 URL:

use Illuminate\Http\Request;

Route::get('/product-checkout', function (Request $request) {
    return $request->user()->checkout(['price_tshirt' => 1], [
        'success_url' => route('your-success-route'),
        'cancel_url' => route('your-cancel-route'),
    ]);
});

定义 success_url 结账选项时,可指示 Stripe 在调用你的 URL 时,将结账会话 ID 作为查询字符串参数添加。为此,需在 success_url 查询字符串中添加字面量 {CHECKOUT_SESSION_ID}。Stripe 会用实际的结账会话 ID 替换该占位符:

use Illuminate\Http\Request;
use Stripe\Checkout\Session;
use Stripe\Customer;

Route::get('/product-checkout', function (Request $request) {
    return $request->user()->checkout(['price_tshirt' => 1], [
        'success_url' => route('checkout-success').'?session_id={CHECKOUT_SESSION_ID}',
        'cancel_url' => route('checkout-cancel'),
    ]);
});

Route::get('/checkout-success', function (Request $request) {
    $checkoutSession = $request->user()->stripe()->checkout->sessions->retrieve($request->get('session_id'));

    return view('checkout.success', ['checkoutSession' => $checkoutSession]);
})->name('checkout-success');

促销码

默认情况下,Stripe Checkout 不允许 用户兑换促销码。好在启用此功能很简单。为此,可调用 allowPromotionCodes 方法:

use Illuminate\Http\Request;

Route::get('/product-checkout', function (Request $request) {
    return $request->user()
        ->allowPromotionCodes()
        ->checkout('price_tshirt');
});

单次收费结账

也可对未在 Stripe 控制台中创建的临时产品执行简单收费。为此,可在可计费模型上使用 checkoutCharge 方法,传入收费金额、产品名称以及可选的数量。当客户访问此路由时,会被重定向到 Stripe 的 Checkout 页面:

use Illuminate\Http\Request;

Route::get('/charge-checkout', function (Request $request) {
    return $request->user()->checkoutCharge(1200, 'T-Shirt', 5);
});

使用 checkoutCharge 方法时,Stripe 会始终在你的 Stripe 控制台中创建新的产品和价格。因此,建议在 Stripe 控制台中预先创建产品,转而使用 checkout 方法。

订阅结账

通过 Stripe Checkout 处理订阅时,需在 Stripe 控制台中启用 customer.subscription.created webhook。该 webhook 会在数据库中创建订阅记录,并存储所有相关的订阅项。

也可用 Stripe Checkout 发起订阅。使用 Cashier 的订阅构建器方法定义订阅后,可调用 checkout 方法。当客户访问此路由时,会被重定向到 Stripe 的 Checkout 页面:

use Illuminate\Http\Request;

Route::get('/subscription-checkout', function (Request $request) {
    return $request->user()
        ->newSubscription('default', 'price_monthly')
        ->checkout();
});

与产品结账一样,可自定义成功和取消 URL:

use Illuminate\Http\Request;

Route::get('/subscription-checkout', function (Request $request) {
    return $request->user()
        ->newSubscription('default', 'price_monthly')
        ->checkout([
            'success_url' => route('your-success-route'),
            'cancel_url' => route('your-cancel-route'),
        ]);
});

当然,也可为订阅结账启用促销码:

use Illuminate\Http\Request;

Route::get('/subscription-checkout', function (Request $request) {
    return $request->user()
        ->newSubscription('default', 'price_monthly')
        ->allowPromotionCodes()
        ->checkout();
});

遗憾的是,Stripe Checkout 在发起订阅时,并不支持所有订阅计费选项。在订阅构建器上使用 anchorBillingCycleOn 方法、设置按比例分摊行为,或设置支付行为,在 Stripe Checkout 会话中均不会生效。请查阅 Stripe Checkout Session API 文档,了解可用参数。

Stripe Checkout 与试用期

当然,在构建通过 Stripe Checkout 完成的订阅时,可定义试用期:

$checkout = Auth::user()->newSubscription('default', 'price_monthly')
    ->trialDays(3)
    ->checkout();

不过,试用期至少需为 48 小时,这是 Stripe Checkout 支持的最短试用时长。

订阅与 Webhook

请注意,Stripe 和 Cashier 通过 webhook 更新订阅状态,因此当客户输入支付信息后返回应用时,订阅可能尚未激活。为应对这种情况,你可能需要显示一条消息,告知用户其支付或订阅正在处理中。

收集税号

Checkout 还支持收集客户的税号。要在结账会话中启用此功能,可在创建会话时调用 collectTaxIds 方法:

$checkout = $user->collectTaxIds()->checkout('price_tshirt');

调用此方法后,客户会看到一个新复选框,可据此表明是否以公司名义购买。若以公司名义购买,客户将有机会提供其税号。

若已在应用的服务提供者中配置自动税务收集功能,该功能会自动启用,无需调用 collectTaxIds 方法。

访客结账

使用 Checkout::guest 方法,可为没有“账户”的应用访客发起结账会话:

use Illuminate\Http\Request;
use Laravel\Cashier\Checkout;

Route::get('/product-checkout', function (Request $request) {
    return Checkout::guest()->create('price_tshirt', [
        'success_url' => route('your-success-route'),
        'cancel_url' => route('your-cancel-route'),
    ]);
});

与为现有用户创建结账会话时类似,您可以使用 Laravel\Cashier\CheckoutBuilder 实例上提供的其他方法来自定义访客结账会话:

use Illuminate\Http\Request;
use Laravel\Cashier\Checkout;

Route::get('/product-checkout', function (Request $request) {
    return Checkout::guest()
        ->withPromotionCode('promo-code')
        ->create('price_tshirt', [
            'success_url' => route('your-success-route'),
            'cancel_url' => route('your-cancel-route'),
        ]);
});

访客结账完成后,Stripe 可以发送 checkout.session.completed Webhook 事件,因此请确保配置您的 Stripe Webhook,以将该事件实际发送到您的应用程序。在 Stripe 控制面板中启用 Webhook 后,您可以使用 Cashier 处理该 Webhook。Webhook 负载中包含的对象是结账对象,您可以检查该对象以完成客户的订单。

处理支付失败

有时,订阅或单次收费的支付可能会失败。发生这种情况时,Cashier 会抛出 Laravel\Cashier\Exceptions\IncompletePayment 异常,告知您发生了此事。捕获该异常后,您有两种选择来继续操作。

首先,您可以将客户重定向到 Cashier 自带的专用支付确认页面。此页面已包含一个通过 Cashier 的服务提供程序注册的关联命名路由。因此,您可以捕获 IncompletePayment 异常并将用户重定向到支付确认页面:

use Laravel\Cashier\Exceptions\IncompletePayment;

try {
    $subscription = $user->newSubscription('default', 'price_monthly')
        ->create($paymentMethod);
} catch (IncompletePayment $exception) {
    return redirect()->route(
        'cashier.payment',
        [$exception->payment->id, 'redirect' => route('home')]
    );
}

在支付确认页面上,系统将提示客户再次输入其信用卡信息,并执行 Stripe 要求的任何额外操作,例如“3D Secure”确认。确认支付后,用户将被重定向到上述 redirect 参数指定的 URL。重定向时,URL 中将添加 message(字符串)和 success(整数)查询字符串变量。支付页面目前支持以下支付方式类型:

  • 信用卡
  • 支付宝
  • Bancontact
  • BECS 直接借记
  • EPS
  • Giropay
  • iDEAL
  • SEPA 直接借记

此外,您可以允许 Stripe 为您处理支付确认。在这种情况下,无需重定向到支付确认页面,您可以在 Stripe 控制面板中设置 Stripe 的自动账单电子邮件。不过,如果捕获到 IncompletePayment 异常,您仍应告知用户,他们将收到一封包含进一步支付确认说明的电子邮件。

在使用了 Billable trait 的模型上,以下方法可能会抛出支付异常:charge、invoiceFor 和 invoice。与订阅交互时,SubscriptionBuilder 上的 create 方法,以及 Subscription 和 SubscriptionItem 模型上的 incrementAndInvoice 和 swapAndInvoice 方法可能会抛出支付未完成异常。

可以使用可计费模型或订阅实例上的 hasIncompletePayment 方法,来确定现有订阅是否存在未完成的支付:

if ($user->hasIncompletePayment('default')) {
    // ...
}

if ($user->subscription('default')->hasIncompletePayment()) {
    // ...
}

通过检查异常实例上的 payment 属性,您可以推断出支付未完成的具体状态:

use Laravel\Cashier\Exceptions\IncompletePayment;

try {
    $user->charge(1000, 'pm_card_threeDSecure2Required');
} catch (IncompletePayment $exception) {
    // 获取支付意向状态...
    $exception->payment->status;

    // 检查特定条件...
    if ($exception->payment->requiresPaymentMethod()) {
        // ...
    } elseif ($exception->payment->requiresConfirmation()) {
        // ...
    }
}

确认支付

某些支付方式需要额外数据才能确认支付。例如,SEPA 支付方式在支付过程中需要额外的“授权(mandate)”数据。您可以使用 withPaymentConfirmationOptions 方法向 Cashier 提供此数据:

$subscription->withPaymentConfirmationOptions([
    'mandate_data' => '...',
])->swap('price_xxx');

您可以参考Stripe API 文档,查看确认支付时接受的所有选项。

强客户认证

如果您的企业或您的客户之一位于欧洲,您将需要遵守欧盟的强客户认证(SCA)法规。这些法规由欧盟于 2019 年 9 月实施,旨在防止支付欺诈。幸运的是,Stripe 和 Cashier 已为构建符合 SCA 要求的应用程序做好准备。

在开始之前,请先查阅Stripe 关于 PSD2 和 SCA 的指南,以及其关于新 SCA API 的文档。

需要额外确认的支付

SCA 法规通常要求进行额外验证,以确认并处理支付。发生这种情况时,Cashier 会抛出 Laravel\Cashier\Exceptions\IncompletePayment 异常,告知您需要进行额外验证。有关如何处理这些异常的更多信息,可在处理支付失败的文档中找到。

Stripe 或 Cashier 提供的支付确认页面可能会针对特定银行或卡发卡机构的支付流程进行定制,其中可能包括额外的卡片确认、临时小额扣款、独立设备认证或其他形式的验证。

未完成和逾期状态

当支付需要额外确认时,订阅将保持 incomplete 或 past_due 状态,具体由其数据库列 stripe_status 指示。一旦支付确认完成,并且您的应用程序通过 Webhook 收到 Stripe 关于其完成的通知,Cashier 将自动激活客户的订阅。

有关 incomplete 和 past_due 状态的更多信息,请参阅我们关于这些状态的补充文档。

非会话支付通知

由于 SCA 法规要求客户即使在订阅处于活跃状态时,也偶尔需要验证其支付详情,因此在需要非会话支付确认时,Cashier 可以向客户发送通知。例如,这种情况可能在订阅续订时发生。通过将环境变量 CASHIER_PAYMENT_NOTIFICATION 设置为通知类,可以启用 Cashier 的支付通知。默认情况下,此通知处于禁用状态。当然,Cashier 包含一个可用于此目的的通知类,但如果您愿意,也可以自行提供通知类:

CASHIER_PAYMENT_NOTIFICATION=Laravel\Cashier\Notifications\ConfirmPayment

为确保非会话支付确认通知能够送达,请确认您的应用程序已配置 Stripe Webhook,并在 Stripe 控制面板中启用了 invoice.payment_action_required Webhook。此外,您的 Billable 模型也应使用 Laravel 的 Illuminate\Notifications\Notifiable trait。

即使客户手动进行需要额外确认的支付,系统也会发送通知。遗憾的是,Stripe 无法知道该支付是手动完成的还是“非会话”完成的。不过,如果客户在已确认支付后再次访问支付页面,只会看到“支付成功”的消息。客户不会被允许意外地两次确认同一笔支付,从而产生意外的二次扣款。

Stripe SDK

Cashier 的许多对象都是对 Stripe SDK 对象的封装。如果您想直接与 Stripe 对象交互,可以方便地使用 asStripe 方法获取它们:

$stripeSubscription = $subscription->asStripeSubscription();

$stripeSubscription->application_fee_percent = 5;

$stripeSubscription->save();

你也可以使用 updateStripeSubscription 方法直接更新 Stripe 订阅:

$subscription->updateStripeSubscription(['application_fee_percent' => 5]);

如果你想直接使用 Stripe\StripeClient 客户端,可以在 Cashier 类上调用 stripe 方法。例如,你可以使用此方法获取 StripeClient 实例,并从你的 Stripe 账户中检索价格列表:

use Laravel\Cashier\Cashier;

$prices = Cashier::stripe()->prices->all();

测试

在测试使用 Cashier 的应用程序时,你可以模拟对 Stripe API 的实际 HTTP 请求;然而,这需要你部分重新实现 Cashier 自身的行为。因此,我们建议允许你的测试请求实际的 Stripe API。虽然这样速度较慢,但能更确信你的应用程序按预期运行,且任何耗时较长的测试可以单独放在其专属的 Pest / PHPUnit 测试组中。

测试时,请记住 Cashier 本身已有一套出色的测试套件,因此你应仅专注于测试自己应用程序的订阅和支付流程,而不是每个底层的 Cashier 行为。

开始之前,请将用于测试的 Stripe 密钥添加到你的 phpunit.xml 文件中:

<env name="STRIPE_SECRET" value="sk_test_<your-key>"/>

此后,在测试过程中与 Cashier 交互时,它将向你的 Stripe 测试环境发送实际的 API 请求。为方便起见,你应该预先在 Stripe 测试账户中填充测试时可能用到的订阅/价格。

为了测试各种计费场景,例如信用卡被拒绝和支付失败,你可以使用 Stripe 提供的大量测试卡号和令牌。