简介

本文档适用于 Cashier Paddle 2.x 与 Paddle Billing 的集成。如果你仍在使用 Paddle Classic,则应使用 Cashier Paddle 1.x。

Laravel Cashier Paddle 为 Paddle 的订阅计费服务提供了富有表现力且流畅的接口。它能处理几乎所有你不想手写的繁琐订阅计费样板代码。除了基础的订阅管理,Cashier 还能处理:切换订阅、订阅“数量”、暂停订阅、取消宽限期等功能。

在深入研究 Cashier Paddle 之前,我们建议你也查看一下 Paddle 的 概念指南和 API 文档。

升级 Cashier

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

安装

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

composer require laravel/cashier-paddle

接下来,你应该使用 vendor:publish Artisan 命令发布 Cashier 的迁移文件:

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

然后,你应该运行应用程序的数据库迁移。Cashier 的迁移会创建一个新的 customers 表。此外,还会创建新的 subscriptions 和 subscription_items 表,用于存储客户的所有订阅。最后,将创建一个 transactions 表,用于存储与客户相关的所有 Paddle 交易:

php artisan migrate

为确保 Cashier 能正确处理所有 Paddle 事件,请记得设置 Cashier 的 webhook 处理。

Paddle Sandbox

在进行本地和预发布开发时,你应该 注册一个 Paddle Sandbox 账户。该账户将为你提供一个沙盒环境,以便在不进行实际支付的情况下测试和开发应用程序。你可以使用 Paddle 的 测试卡号来模拟各种支付场景。

使用 Paddle Sandbox 环境时,你应该在应用程序的 .env 文件中将 PADDLE_SANDBOX 环境变量设置为 true:

PADDLE_SANDBOX=true

完成应用开发后,你可以 申请一个 Paddle 商户账户。在应用正式上线前,Paddle 需要审核通过你应用的域名。

配置

可计费模型

在使用 Cashier 之前,你必须在用户模型定义中添加 Billable trait。该 trait 提供了多种方法,让你能够执行常见的计费任务,例如创建订阅和更新支付方式信息:

use Laravel\Paddle\Billable;

class User extends Authenticatable
{
    use Billable;
}

如果你有非用户的可计费实体,也可以将这些 trait 添加到对应的类中:

use Illuminate\Database\Eloquent\Model;
use Laravel\Paddle\Billable;

class Team extends Model
{
    use Billable;
}

API 密钥

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

PADDLE_CLIENT_SIDE_TOKEN=your-paddle-client-side-token
PADDLE_API_KEY=your-paddle-api-key
PADDLE_RETAIN_KEY=your-paddle-retain-key
PADDLE_WEBHOOK_SECRET="your-paddle-webhook-secret"
PADDLE_SANDBOX=true

当你使用 Paddle 的沙盒环境时,应将 PADDLE_SANDBOX 环境变量设置为 true。如果你正在将应用部署到生产环境,并使用 Paddle 的正式商户环境,则应将 PADDLE_SANDBOX 变量设置为 false。

PADDLE_RETAIN_KEY 是可选的,仅在使用 Retain 功能时才需要设置。

Paddle JS

Paddle 依赖其自身的 JavaScript 库来启动 Paddle 结算组件。你可以通过将 @paddleJS Blade 指令放置在应用布局的结束标签 </head> 之前来加载该 JavaScript 库:

<head>
    ...

    @paddleJS
</head>

货币配置

你可以指定一个区域设置,用于格式化发票中展示的金额值。在内部,Cashier 使用 PHP 的 NumberFormatter 类来设置货币区域:

CASHIER_CURRENCY_LOCALE=nl_BE

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

覆盖默认模型

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

use Laravel\Paddle\Subscription as CashierSubscription;

class Subscription extends CashierSubscription
{
    // ...
}

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

use App\Models\Cashier\Subscription;
use App\Models\Cashier\Transaction;

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

快速入门

销售产品

在使用 Paddle Checkout 之前,你应该在 Paddle 控制台中定义具有固定价格的产品。此外,你还应该配置 Paddle 的 webhook 处理。

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

要对客户收取非周期性的一次性产品费用,我们将使用 Cashier 通过 Paddle 的 Checkout Overlay 对客户发起收费,客户将在此提供支付详情并确认购买。一旦通过 Checkout Overlay 完成支付,客户将被重定向到你应用中自定义的成功 URL:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $request->user()->checkout('pri_deluxe_album')
        ->returnTo(route('dashboard'));

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

正如你在上面示例中所见,我们将使用 Cashier 提供的 checkout 方法创建一个结算对象,以便为客户展示针对给定“价格标识符”的 Paddle Checkout Overlay。在使用 Paddle 时,“价格”指的是 特定产品的预设价格。

如有必要,checkout 方法会自动在 Paddle 中创建客户,并将该 Paddle 客户记录与应用数据库中对应的用户关联起来。完成结算会话后,客户将被重定向到一个专门的成功页面,你可以在该页面向客户展示提示信息。

在 buy 视图中,我们将包含一个用于显示 Checkout Overlay 的按钮。paddle-button Blade 组件随 Cashier Paddle 一起提供;不过,你也可以手动渲染叠加式结算页面:

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    Buy Product
</x-paddle-button>

向 Paddle Checkout 提供元数据

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

为此,你可以向 checkout 方法提供一个自定义数据数组。假设用户开始结算流程时,应用内会创建一个待处理的 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',
    ]);

    $checkout = $request->user()->checkout($order->price_ids)
        ->customData(['order_id' => $order->id]);

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

如上例所示,当用户开始结账流程时,我们会将购物车/订单的所有关联 Paddle 价格标识符提供给 checkout 方法。当然,你的应用需要负责在客户添加商品时,将这些商品与“购物车”或订单关联起来。我们还通过 customData 方法,将订单 ID 提供给 Paddle 结账浮层。

当然,一旦客户完成结账流程,你很可能需要将订单标记为“complete(已完成)”。为此,你可以监听 Paddle 分发且由 Cashier 通过事件触发的 webhook,从而将订单信息存储到你的数据库中。

首先,请监听 Cashier 分发的 TransactionCompleted 事件。通常,你应该在应用的 AppServiceProvider 的 boot 方法中注册该事件监听器:

use App\Listeners\CompleteOrder;
use Illuminate\Support\Facades\Event;
use Laravel\Paddle\Events\TransactionCompleted;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Event::listen(TransactionCompleted::class, CompleteOrder::class);
}

在此示例中,CompleteOrder 监听器可能如下所示:

namespace App\Listeners;

use App\Models\Order;
use Laravel\Paddle\Cashier;
use Laravel\Paddle\Events\TransactionCompleted;

class CompleteOrder
{
    /**
     * Handle the incoming Cashier webhook event.
     */
    public function handle(TransactionCompleted $event): void
    {
        $orderId = $event->payload['data']['custom_data']['order_id'] ?? null;

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

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

关于 transaction.completed 事件所包含的数据的更多信息,请参阅 Paddle 的文档。

销售订阅

在使用 Paddle Checkout 之前,你应该先在 Paddle 后台定义具有固定价格的产品。此外,你还需要配置 Paddle 的 webhook 处理。

通过应用提供产品和订阅计费功能可能令人望而生畏。不过,得益于 Cashier 和 Paddle 的结账浮层(Checkout Overlay),你可以轻松构建现代化且稳健的支付集成。

为了了解如何使用 Cashier 和 Paddle 的结账浮层销售订阅,我们先来看一个简单的订阅服务场景,其中包含基础的月度套餐(price_basic_monthly)和年度套餐(price_basic_yearly)。在 Paddle 后台中,这两个价格可以归入“Basic(基础版)”产品(pro_basic)。此外,我们的订阅服务还可以提供“Expert(专家版)”套餐,对应为 pro_expert。

首先,我们来看看客户如何订阅我们的服务。当然,你可以想象,客户会在我们应用的定价页面上点击 Basic 套餐的“subscribe(订阅)”按钮。该按钮会针对所选套餐调用 Paddle 结账浮层。首先,我们通过 checkout 方法来启动一个结账会话:

use Illuminate\Http\Request;

Route::get('/subscribe', function (Request $request) {
    $checkout = $request->user()->checkout('price_basic_monthly')
        ->returnTo(route('dashboard'));

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

在 subscribe 视图中,我们将添加一个按钮来显示结账浮层。Cashier Paddle 自带 paddle-button Blade 组件,不过,你也可以手动渲染一个结账浮层:

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    Subscribe
</x-paddle-button>

现在,当点击“Subscribe(订阅)”按钮时,客户就可以输入支付信息并发起订阅。为了知晓订阅何时真正开始(因为某些支付方式需要几秒钟进行处理),你还需要配置 Cashier 的 webhook 处理。

既然客户可以开始订阅,我们需要限制应用中的某些部分,确保只有已订阅的用户才能访问。当然,我们可以随时通过 Cashier 的 Billable trait 提供的 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

构建订阅中间件

为方便起见,你可能需要创建一个中间件(middleware),用于判断传入请求是否来自已订阅用户。定义好该中间件后,你可以将其轻松分配给某个路由,从而阻止未订阅用户访问该路由:

<?php

namespace App\Http\Middleware;

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

class Subscribed
{
    /**
     * 处理传入请求。
     */
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->subscribed()) {
            // 将用户重定向到计费页面,并要求他们订阅……
            return redirect('/subscribe');
        }

        return $next($request);
    }
}

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

use App\Http\Middleware\Subscribed;

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

允许客户管理其计费套餐

当然,客户可能希望将订阅套餐更改为其他产品或“层级”。在上面的示例中,我们希望允许客户将套餐从月度订阅更改为年度订阅。为此,你需要实现一个按钮,指向以下路由:

use Illuminate\Http\Request;

Route::put('/subscription/{price}/swap', function (Request $request, $price) {
    $user->subscription()->swap($price); // With "$price" being "price_basic_yearly" for this example.

    return redirect()->route('dashboard');
})->name('subscription.swap');

除了更换套餐,你还需要允许客户取消订阅。与更换套餐一样,提供一个按钮,指向以下路由:

use Illuminate\Http\Request;

Route::put('/subscription/cancel', function (Request $request, $price) {
    $user->subscription()->cancel();

    return redirect()->route('dashboard');
})->name('subscription.cancel');

这样一来,你的订阅将在当前计费周期结束时被取消。

只要配置好 Cashier 的 webhook 处理,Cashier 就会通过检查从 Paddle 传入的 webhook,自动同步应用中与 Cashier 相关的数据库表。因此,例如,当你通过 Paddle 后台取消客户订阅时,Cashier 会收到对应的 webhook,并在应用的数据库中将该订阅标记为“canceled(已取消)”。

结账会话

大多数客户计费操作,都是通过 Paddle 的结账浮层(Checkout Overlay)小部件发起“结账”来完成,或是通过内联结账(inline checkout)来实现。

在使用 Paddle 处理结账支付之前,你应该先在 Paddle 的结账设置后台中,定义应用的默认支付链接。

浮层结账

在显示结账浮层小部件之前,你必须使用 Cashier 生成一个结账会话。结账会话会告知结账小部件应执行的计费操作:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));

    return view('billing', ['checkout' => $checkout]);
});

Cashier 包含一个 paddle-button Blade 组件。您可以将结账会话作为“prop”传递给此组件。然后,当点击此按钮时,Paddle 的结账小部件将会显示:

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    订阅
</x-paddle-button>

默认情况下,这会使用 Paddle 的默认样式显示小部件。您可以通过向组件添加Paddle 支持的属性(如 data-theme='light' 属性)来自定义小部件:

<x-paddle-button :checkout="$checkout" class="px-8 py-4" data-theme="light">
    订阅
</x-paddle-button>

Paddle 结账小部件是异步的。一旦用户在小部件内创建了订阅,Paddle 就会向您的应用程序发送 Webhook,以便您在应用程序的数据库中正确更新订阅状态。因此,务必正确设置 Webhook,以适应来自 Paddle 的状态变化。

订阅状态发生变化后,接收相应 Webhook 的延迟通常极小,但您应在应用程序中考虑到这一点,即用户完成结账后,其订阅可能不会立即可用。

手动渲染浮层结账

您也可以不使用 Laravel 内置的 Blade 组件,手动渲染浮层结账。首先,按照前面的示例生成结账会话:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));

    return view('billing', ['checkout' => $checkout]);
});

接下来,您可以使用 Paddle.js 来初始化结账。在此示例中,我们将创建一个被赋予 paddle_button 类的链接。Paddle.js 会检测到此类,并在点击链接时显示浮层结账:

<?php
$items = $checkout->getItems();
$customer = $checkout->getCustomer();
$custom = $checkout->getCustomData();
?>

<a
    href='#!'
    class='paddle_button'
    data-items='{!! json_encode($items) !!}'
    @if ($customer) data-customer-id='{{ $customer->paddle_id }}' @endif
    @if ($custom) data-custom-data='{{ json_encode($custom) }}' @endif
    @if ($returnUrl = $checkout->getReturnUrl()) data-success-url='{{ $returnUrl }}' @endif
>
    购买产品
</a>

内联结账

如果您不想使用 Paddle 的“浮层”样式结账小部件,Paddle 还提供了内联显示小部件的选项。虽然这种方法不允许您调整结账的任何 HTML 字段,但它允许您将小部件嵌入到您的应用程序中。

为了帮助您轻松开始使用内联结账,Cashier 包含了一个 paddle-checkout Blade 组件。首先,您需要生成一个结账会话:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));

    return view('billing', ['checkout' => $checkout]);
});

然后,您可以将结账会话传递给组件的 checkout 属性:

<x-paddle-checkout :checkout="$checkout" class="w-full" />

要调整内联结账组件的高度,您可以将 height 属性传递给 Blade 组件:

<x-paddle-checkout :checkout="$checkout" class="w-full" height="500" />

有关内联结账自定义选项的更多详情,请参阅 Paddle 的内联结账指南以及可用结账设置。

手动渲染内联结账

您也可以不使用 Laravel 内置的 Blade 组件,手动渲染内联结账。首先,按照前面的示例生成结账会话:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));

    return view('billing', ['checkout' => $checkout]);
});

接下来,您可以使用 Paddle.js 来初始化结账。在此示例中,我们将使用 Alpine.js 进行演示;不过,您可以根据自己的前端技术栈自由修改此示例:

<?php
$options = $checkout->options();

$options['settings']['frameTarget'] = 'paddle-checkout';
$options['settings']['frameInitialHeight'] = 366;
?>

<div class="paddle-checkout" x-data="{}" x-init="
    Paddle.Checkout.open(@json($options));
">
</div>

访客结账

有时,您可能需要为不需要在您的应用程序中拥有账户的用户创建结账会话。为此,您可以使用 guest 方法:

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

Route::get('/buy', function (Request $request) {
    $checkout = Checkout::guest(['pri_34567'])
        ->returnTo(route('home'));

    return view('billing', ['checkout' => $checkout]);
});

然后,您可以将结账会话提供给 Paddle 按钮或内联结账 Blade 组件。

价格预览

Paddle 允许您按货币自定义价格,本质上允许您为不同国家配置不同的价格。Cashier Paddle 允许您使用 previewPrices 方法获取所有这些价格。此方法接受您希望获取价格的价格 ID:

use Laravel\Paddle\Cashier;

$prices = Cashier::previewPrices(['pri_123', 'pri_456']);

货币将根据请求的 IP 地址确定;不过,您可以选择性地提供特定国家以获取价格:

use Laravel\Paddle\Cashier;

$prices = Cashier::previewPrices(['pri_123', 'pri_456'], ['address' => [
    'country_code' => 'BE',
    'postal_code' => '1234',
]]);

获取价格后,您可以按照自己的需求展示它们:

<ul>
    @foreach ($prices as $price)
        <li>{{ $price->product['name'] }} - {{ $price->total() }}</li>
    @endforeach
</ul>

您还可以分别显示小计价格和税额:

<ul>
    @foreach ($prices as $price)
        <li>{{ $price->product['name'] }} - {{ $price->subtotal() }} (+ {{ $price->tax() }} 税额)</li>
    @endforeach
</ul>

欲了解更多信息,请查阅 Paddle 关于价格预览的 API 文档。

客户价格预览

如果用户已经是客户,并且您希望显示适用于该客户的价格,您可以直接从客户实例中获取价格来实现:

use App\Models\User;

$prices = User::find(1)->previewPrices(['pri_123', 'pri_456']);

在内部,Cashier 将使用用户的客户 ID 来获取以其货币计价的价格。因此,例如,居住在美国的用户将以美元看到价格,而比利时的用户将以欧元看到价格。如果找不到匹配的货币,将使用产品的默认货币。您可以在 Paddle 控制面板中自定义产品或订阅计划的所有价格。

折扣

您还可以选择显示折扣后的价格。调用 previewPrices 方法时,您可以通过 discount_id 选项提供折扣 ID:

use Laravel\Paddle\Cashier;

$prices = Cashier::previewPrices(['pri_123', 'pri_456'], [
    'discount_id' => 'dsc_123'
]);

然后,显示计算后的价格:

<ul>
    @foreach ($prices as $price)
        <li>{{ $price->product['name'] }} - {{ $price->total() }}</li>
    @endforeach
</ul>

客户

客户默认值

Cashier 允许您在创建结账会话时为客户定义一些有用的默认值。设置这些默认值可以预填客户的电子邮件地址和姓名,以便他们可以立即进入结账小部件的支付部分。您可以通过在可计费模型上重写以下方法来设置这些默认值:

/**
 * 获取要与 Paddle 关联的客户姓名。
 */
public function paddleName(): string|null
{
    return $this->name;
}

/**
 * 获取要与 Paddle 关联的客户电子邮件地址。
 */
public function paddleEmail(): string|null
{
    return $this->email;
}

这些默认值将用于 Cashier 中生成结账会话的每一次操作。

获取客户

你可以使用 Cashier::findBillable 方法,通过 Paddle 客户 ID 来获取客户信息。此方法会返回一个可计费模型实例:

use Laravel\Paddle\Cashier;

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

创建客户

有时,你可能想在不开始订阅的情况下创建 Paddle 客户。你可以使用 createAsCustomer 方法来实现这一点:

$customer = $user->createAsCustomer();

该方法会返回 Laravel\Paddle\Customer 的实例。一旦客户在 Paddle 中创建完成,你可以在之后的某个时间点开始订阅。你可以提供一个可选的 $options 数组,用于传入 Paddle API 支持的客户创建参数:

$customer = $user->createAsCustomer($options);

订阅

创建订阅

要创建订阅,首先从数据库中获取可计费模型的实例,通常会是 App\Models\User 的实例。获取模型实例后,你可以使用 subscribe 方法来创建该模型的结账会话:

use Illuminate\Http\Request;

Route::get('/user/subscribe', function (Request $request) {
    $checkout = $request->user()->subscribe($premium = 'pri_123', 'default')
        ->returnTo(route('home'));

    return view('billing', ['checkout' => $checkout]);
});

传递给 subscribe 方法的第一个参数是用户要订阅的具体价格。该值应与 Paddle 中该价格的标识符相匹配。returnTo 方法接受一个 URL,用户成功完成结账后将被重定向到该 URL。传递给 subscribe 方法的第二个参数应为订阅的内部“类型”。如果你的应用仅提供单一订阅,你可以将其命名为 default 或 primary。此订阅类型仅用于应用内部,不面向用户展示。此外,该类型不得包含空格,且在创建订阅后绝不能更改。

你还可以使用 customData 方法,传入有关订阅的自定义元数据数组:

$checkout = $request->user()->subscribe($premium = 'pri_123', 'default')
    ->customData(['key' => 'value'])
    ->returnTo(route('home'));

订阅结账会话创建完成后,可以将该结账会话传递给 Cashier Paddle 自带的 paddle-button Blade 组件:

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    订阅
</x-paddle-button>

用户完成结账后,Paddle 会发送一个 subscription_created 网络钩子。Cashier 将接收该网络钩子,并为客户设置订阅。为确保应用能正确接收和处理所有网络钩子,请确保已正确配置网络钩子处理。

检查订阅状态

用户订阅你的应用后,你可以通过多种便捷方法检查其订阅状态。首先,如果用户拥有有效的订阅(即使该订阅当前处于试用期内),subscribed 方法会返回 true:

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

如果你的应用提供多种订阅,调用 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()) {
            // 该用户不是付费客户……
            return redirect('/billing');
        }

        return $next($request);
    }
}

如果你想判断用户是否仍处于试用期,可以使用 onTrial 方法。此方法可用于判断是否需要向用户显示仍处于试用期的提示信息:

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

subscribedToPrice 方法可用于根据给定的 Paddle 价格 ID,判断用户是否订阅了指定套餐。在本示例中,我们将判断用户的 default 订阅是否正在订阅月度价格:

if ($user->subscribedToPrice($monthly = 'pri_123', 'default')) {
    // ...
}

recurring 方法可用于判断用户当前是否处于有效订阅状态,且不再处于试用期或宽限期:

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

已取消订阅状态

若要判断用户曾经是有效订阅者但已取消订阅,可以使用 canceled 方法:

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

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

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

逾期未付状态

如果订阅支付失败,其状态将被标记为 past_due。当订阅处于该状态时,在客户更新支付信息之前,订阅不会处于有效状态。你可以使用订阅实例上的 pastDue 方法,判断订阅是否逾期未付:

if ($user->subscription()->pastDue()) {
    // ...
}

当订阅逾期未付时,你应引导用户更新支付信息。

如果你希望订阅处于 past_due 状态时仍被视为有效,可以使用 Cashier 提供的 keepPastDueSubscriptionsActive 方法。通常,该方法应在你的 AppServiceProvider 的 register 方法中调用:

use Laravel\Paddle\Cashier;

/**
 * 注册应用服务。
 */
public function register(): void
{
    Cashier::keepPastDueSubscriptionsActive();
}

当订阅处于 past_due 状态时,在更新支付信息前无法对其进行更改。因此,当订阅处于 past_due 状态时,调用 swap 和 updateQuantity 方法会抛出异常。

订阅作用域

大多数订阅状态也支持作为查询作用域使用,这样你就可以轻松地从数据库中查询处于特定状态的订阅:

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

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

可用作用域的完整列表如下:

Subscription::query()->valid();
Subscription::query()->onTrial();
Subscription::query()->expiredTrial();
Subscription::query()->notOnTrial();
Subscription::query()->active();
Subscription::query()->recurring();
Subscription::query()->pastDue();
Subscription::query()->paused();
Subscription::query()->notPaused();
Subscription::query()->onPausedGracePeriod();
Subscription::query()->notOnPausedGracePeriod();
Subscription::query()->canceled();
Subscription::query()->notCanceled();
Subscription::query()->onGracePeriod();
Subscription::query()->notOnGracePeriod();

订阅单次收费

订阅单次收费功能允许你在订阅的基础上,向订阅者收取一次性费用。调用 charge 方法时,你必须提供一个或多个价格 ID:

// 对单个价格收费……
$response = $user->subscription()->charge('pri_123');

// 同时对多个价格收费……
$response = $user->subscription()->charge(['pri_123', 'pri_456']);

charge 方法在客户的订阅进入下一个计费周期前,不会真正向客户扣费。如果你想立即向客户收费,可以改用 chargeAndInvoice 方法:

$response = $user->subscription()->chargeAndInvoice('pri_123');

更新付款信息

Paddle 始终为每个订阅保存一种付款方式。如果你想更新订阅的默认付款方式,应使用订阅模型上的 redirectToUpdatePaymentMethod 方法,将客户重定向到 Paddle 托管的付款方式更新页面:

use Illuminate\Http\Request;

Route::get('/update-payment-method', function (Request $request) {
    $user = $request->user();

    return $user->subscription()->redirectToUpdatePaymentMethod();
});

当用户完成信息更新后,Paddle 会派发 subscription_updated Webhook,订阅详情也会在应用的数据库中更新。

更换套餐

用户订阅你的应用后,偶尔可能需要更换为新的订阅套餐。要更新用户的订阅套餐,应将 Paddle 价格标识符传递给订阅的 swap 方法:

use App\Models\User;

$user = User::find(1);

$user->subscription()->swap($premium = 'pri_456');

如果你想在更换套餐的同时立即向用户开具账单,而不是等待下一个计费周期,可以使用 swapAndInvoice 方法:

$user = User::find(1);

$user->subscription()->swapAndInvoice($premium = 'pri_456');

按比例计费

默认情况下,Paddle 在套餐之间切换时会按比例计算费用。可以使用 noProrate 方法更新订阅,而不进行按比例计费:

$user->subscription('default')->noProrate()->swap($premium = 'pri_456');

如果你希望禁用按比例计费并立即向客户开具账单,可以将 swapAndInvoice 方法与 noProrate 结合使用:

$user->subscription('default')->noProrate()->swapAndInvoice($premium = 'pri_456');

或者,若要在订阅变更时不向客户收费,可以使用 doNotBill 方法:

$user->subscription('default')->doNotBill()->swap($premium = 'pri_456');

有关 Paddle 按比例计费策略的更多信息,请参阅 Paddle 的按比例计费文档。

订阅数量

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

$user = User::find(1);

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

// Add five to the subscription's current quantity...
$user->subscription()->incrementQuantity(5);

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

// Subtract five from the subscription's current quantity...
$user->subscription()->decrementQuantity(5);

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

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

可以使用 noProrate 方法更新订阅数量,而不进行按比例计费:

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

多产品订阅的数量

如果你的订阅是包含多个产品的订阅,应将希望增加或减少数量的价格 ID 作为第二个参数传递给增量/减量方法:

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

多产品订阅

多产品订阅允许你为单个订阅分配多个计费产品。例如,假设你正在开发一个客户服务“服务台”应用,其基础订阅价格为每月 10 美元,同时提供每月额外 15 美元的实时聊天附加产品。

创建订阅结算会话时,可以通过将价格数组作为第一个参数传递给 subscribe 方法,为给定订阅指定多个产品:

use Illuminate\Http\Request;

Route::post('/user/subscribe', function (Request $request) {
    $checkout = $request->user()->subscribe([
        'price_monthly',
        'price_chat',
    ]);

    return view('billing', ['checkout' => $checkout]);
});

在上面的示例中,客户将在其 default 订阅上关联两个价格。两个价格都会按各自的计费周期收费。如有需要,你可以传入键/值对组成的关联数组,以指定每个价格的具体数量:

$user = User::find(1);

$checkout = $user->subscribe(['price_monthly', 'price_chat' => 5]);

如果你想为现有订阅添加另一个价格,必须使用订阅的 swap 方法。调用 swap 方法时,还应同时包含订阅当前的价格和数量:

$user = User::find(1);

$user->subscription()->swap(['price_chat', 'price_original' => 2]);

上面的示例将添加新价格,但在下一个计费周期前不会向客户收费。如果你想立即向客户收费,可以使用 swapAndInvoice 方法:

$user->subscription()->swapAndInvoice(['price_chat', 'price_original' => 2]);

你可以使用 swap 方法从订阅中移除价格,只需省略要移除的价格即可:

$user->subscription()->swap(['price_original' => 2]);

你不能移除订阅中的最后一个价格。此时,你应当直接取消该订阅。

多个订阅

Paddle 允许客户同时拥有多个订阅。例如,你可以运营一家健身房,提供游泳订阅和举重订阅,且每个订阅的定价可能不同。当然,客户应该能够订阅其中一个或两个套餐。

当你的应用创建订阅时,可以将订阅类型作为第二个参数提供给 subscribe 方法。该类型可以是表示用户正在发起的订阅类型的任意字符串:

use Illuminate\Http\Request;

Route::post('/swimming/subscribe', function (Request $request) {
    $checkout = $request->user()->subscribe($swimmingMonthly = 'pri_123', 'swimming');

    return view('billing', ['checkout' => $checkout]);
});

在这个示例中,我们为客户发起了一个月度游泳订阅。不过,他们之后可能希望更换为年度订阅。在调整客户订阅时,我们只需更换 swimming 订阅上的价格即可:

$user->subscription('swimming')->swap($swimmingYearly = 'pri_456');

当然,你也可以直接取消该订阅:

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

暂停订阅

要暂停订阅,请在用户的订阅上调用 pause 方法:

$user->subscription()->pause();

当订阅被暂停时,Cashier 会自动设置数据库中的 paused_at 列。该列用于确定 paused 方法何时开始返回 true。例如,如果客户在 3 月 1 日暂停订阅,但订阅原定于 3 月 5 日才续订,那么 paused 方法会在 3 月 5 日之前持续返回 false。这是因为用户通常被允许在计费周期结束前继续使用应用。

默认情况下,暂停会在下一个计费周期生效,以便客户可以使用其已付费用所对应的剩余时间。如果你想立即暂停订阅,可以使用 pauseNow 方法:

$user->subscription()->pauseNow();

使用 pauseUntil 方法,可以将订阅暂停到某个特定时间点:

$user->subscription()->pauseUntil(now()->plus(months: 1));

或者,你可以使用 pauseNowUntil 方法,立即将订阅暂停到指定时间点:

$user->subscription()->pauseNowUntil(now()->plus(months: 1));

你可以使用 onPausedGracePeriod 方法来判断用户是否暂停了订阅,但仍处于“宽限期”:

if ($user->subscription()->onPausedGracePeriod()) {
    // ...
}

若要恢复已暂停的订阅,你可以对订阅调用 resume 方法:

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

订阅处于暂停状态时无法进行修改。如果你想要切换到其他套餐或更新数量,必须先恢复订阅。

取消订阅

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

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

当订阅被取消时,Cashier 会自动设置数据库中的 ends_at 列。该列用于确定 subscribed 方法应何时开始返回 false。例如,若客户在 3 月 1 日取消了订阅,但该订阅原定于 3 月 5 日结束,subscribed 方法将继续返回 true,直至 3 月 5 日。之所以这样处理,是因为通常会允许用户继续使用应用,直到其计费周期结束。

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

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

如果你希望立即取消订阅,可以对订阅调用 cancelNow 方法:

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

若要阻止处于宽限期的订阅被取消,可以调用 stopCancelation 方法:

$user->subscription()->stopCancelation();

Paddle 的订阅在取消后无法恢复。如果你的客户想要恢复订阅,他们必须创建新的订阅。

订阅试用

预先收集支付方式

如果你希望为客户提供试用周期,同时又要提前收集支付方式信息,应在 Paddle 后台针对客户订阅的价格设置试用时长。然后,照常发起结算会话:

use Illuminate\Http\Request;

Route::get('/user/subscribe', function (Request $request) {
    $checkout = $request->user()
        ->subscribe('pri_monthly')
        ->returnTo(route('home'));

    return view('billing', ['checkout' => $checkout]);
});

当你的应用接收到 subscription_created 事件时,Cashier 会在应用数据库的订阅记录中设置试用结束日期,并指示 Paddle 在该日期之后再开始向客户收费。

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

你可以使用用户实例的 onTrial 方法,判断用户是否处于试用期间:

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

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

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

若要判断用户是否正在使用某特定订阅类型的试用,可以向 onTrial 或 hasExpiredTrial 方法传入该类型:

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

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

不预先收集支付方式

如果你希望提供试用周期,且不提前收集用户的支付方式信息,可以在关联到用户的客户记录上,将 trial_ends_at 列设为你期望的试用结束日期。这通常在用户注册时完成:

use App\Models\User;

$user = User::create([
    // ...
]);

$user->createAsCustomer([
    'trial_ends_at' => now()->plus(days: 10)
]);

Cashier 将这种试用称为“通用试用”,因为它不与任何现有订阅关联。若当前日期未超过 trial_ends_at 的值,User 实例的 onTrial 方法将返回 true:

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

当你准备好为用户创建正式订阅时,可以照常使用 subscribe 方法:

use Illuminate\Http\Request;

Route::get('/user/subscribe', function (Request $request) {
    $checkout = $request->user()
        ->subscribe('pri_monthly')
        ->returnTo(route('home'));

    return view('billing', ['checkout' => $checkout]);
});

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

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

如果你希望明确判断用户是否处于“通用”试用期间,且尚未创建正式订阅,可以使用 onGenericTrial 方法:

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

延长或激活试用

你可以通过调用 extendTrial 方法并指定试用应结束的时间,来延长订阅的现有试用周期:

$user->subscription()->extendTrial(now()->plus(days: 5));

或者,你也可以对订阅调用 activate 方法,结束试用并立即激活订阅:

$user->subscription()->activate();

处理 Paddle 的 Webhooks

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

默认情况下,该控制器会自动处理因多次扣费失败而被取消的订阅、订阅更新以及支付方式变更;不过,正如我们接下来会看到的,你可以扩展该控制器以处理任意你所需的 Paddle webhook 事件。

为确保你的应用能够处理 Paddle 的 webhooks,请务必在 Paddle 控制面板中配置 webhook URL。默认情况下,Cashier 的 webhook 控制器会响应 /paddle/webhook URL 路径。你应该在 Paddle 控制面板中启用的 webhooks 完整列表如下:

  • 客户已更新
  • 交易已完成
  • 交易已更新
  • 订阅已创建
  • 订阅已更新
  • 订阅已暂停
  • 订阅已取消

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

Webhooks 与 CSRF 保护

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

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

Webhooks 与本地开发

为了让 Paddle 能够在本地开发期间向你的应用发送 webhooks,你需要通过站点共享服务暴露你的应用,例如 Ngrok 或 Expose。如果你正使用 Laravel Sail 在本地开发应用,可以使用 Sail 的站点共享命令。

定义 Webhook 事件处理器

Cashier 会自动处理因扣费失败导致的订阅取消以及其他常见的 Paddle webhooks。不过,如果你需要处理其他 webhook 事件,可以通过监听由 Cashier 触发的以下事件来实现:

  • Laravel\Paddle\Events\WebhookReceived
  • Laravel\Paddle\Events\WebhookHandled

这两个事件都包含 Paddle webhook 的完整载荷。例如,如果你希望处理 transaction.billed webhook,可以注册一个监听器来处理该事件:

<?php

namespace App\Listeners;

use Laravel\Paddle\Events\WebhookReceived;

class PaddleEventListener
{
    /**
     * 处理接收到的 Paddle webhook。
     */
    public function handle(WebhookReceived $event): void
    {
        if ($event->payload['event_type'] === 'transaction.billed') {
            // 处理传入的事件……
        }
    }
}

Cashier 还会发出专门针对接收到的 webhook 类型的事件。除了来自 Paddle 的完整载荷外,这些事件还包含用于处理 webhook 的相关模型,例如可计费模型、订阅或收据:

  • Laravel\Paddle\Events\CustomerUpdated
  • Laravel\Paddle\Events\TransactionCompleted
  • Laravel\Paddle\Events\TransactionUpdated
  • Laravel\Paddle\Events\SubscriptionCreated
  • Laravel\Paddle\Events\SubscriptionUpdated
  • Laravel\Paddle\Events\SubscriptionPaused
  • Laravel\Paddle\Events\SubscriptionCanceled

你还可以通过在应用程序的 .env 文件中定义 CASHIER_WEBHOOK 环境变量,来覆盖默认的内置 webhook 路由。此值应为你的 webhook 路由的完整 URL,并且需要与你在 Paddle 控制面板中设置的 URL 相匹配:

CASHIER_WEBHOOK=https://example.com/my-paddle-webhook-url

验证 Webhook 签名

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

要启用 webhook 验证,请确保在应用程序的 .env 文件中定义了 PADDLE_WEBHOOK_SECRET 环境变量。webhook 密钥可以从你的 Paddle 账户仪表板中获取。

单次扣款

产品扣款

如果你想为客户发起产品购买,可以在可计费模型实例上使用 checkout 方法,为此次购买生成一个结账会话。checkout 方法接受一个或多个价格 ID。如有必要,可以使用关联数组来指定所购买产品的数量:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $request->user()->checkout(['pri_tshirt', 'pri_socks' => 5]);

    return view('buy', ['checkout' => $checkout]);
});

生成结账会话后,你可以使用 Cashier 提供的 paddle-button Blade 组件,让用户查看 Paddle 结账组件并完成购买:

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    购买
</x-paddle-button>

结账会话有一个 customData 方法,允许你将任何自定义数据传递给底层的交易创建流程。请参阅Paddle 文档,了解传递自定义数据时可用的选项:

$checkout = $user->checkout('pri_tshirt')
    ->customData([
        'custom_option' => $value,
    ]);

退款交易

对交易进行退款会将退款金额返还到客户在购买时所使用的支付方式。如果需要对 Paddle 购买进行退款,可以在 Cashier\Paddle\Transaction 模型上使用 refund 方法。该方法接受一个原因作为第一个参数,一个或多个要退款的价格 ID,以及一个包含可选金额的关联数组。你可以使用 transactions 方法获取给定可计费模型的交易。

例如,假设我们要为价格 pri_123 和 pri_456 的特定交易退款。我们希望对 pri_123 全额退款,但仅对 pri_456 退款两美元:

use App\Models\User;

$user = User::find(1);

$transaction = $user->transactions()->first();

$response = $transaction->refund('意外扣款', [
    'pri_123', // 对此价格全额退款……
    'pri_456' => 200, // 仅对此价格部分退款……
]);

上面的示例对交易中的特定行项目进行了退款。如果要对整笔交易退款,只需提供一个原因:

$response = $transaction->refund('意外扣款');

有关退款的更多信息,请参阅Paddle 的退款文档。

退款在完全处理前,必须始终得到 Paddle 的批准。

入账交易

与退款类似,你也可以为交易入账。为交易入账会将资金添加到客户的余额中,以便用于未来的购买。入账操作只能针对手动收款的交易,不能针对自动收款的交易(如订阅),因为 Paddle 会自动处理订阅入账:

$transaction = $user->transactions()->first();

// 对特定行项目全额入账……
$response = $transaction->credit('补偿', 'pri_123');

更多信息,请参阅 Paddle 关于入账的文档。

入账仅适用于手动收款的交易。自动收款的交易由 Paddle 自行入账。

交易

你可以通过 transactions 属性轻松获取可计费模型的交易数组:

use App\Models\User;

$user = User::find(1);

$transactions = $user->transactions;

交易代表对你的产品和购买的付款,并附带发票。只有已完成的交易才会存储在应用程序的数据库中。

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

<table>
    @foreach ($transactions as $transaction)
        <tr>
            <td>{{ $transaction->billed_at->toFormattedDateString() }}</td>
            <td>{{ $transaction->total() }}</td>
            <td>{{ $transaction->tax() }}</td>
            <td><a href="{{ route('download-invoice', $transaction->id) }}" target="_blank">下载</a></td>
        </tr>
    @endforeach
</table>

download-invoice 路由可能如下所示:

use Illuminate\Http\Request;
use Laravel\Paddle\Transaction;

Route::get('/download-invoice/{transaction}', function (Request $request, Transaction $transaction) {
    return $transaction->redirectToInvoicePdf();
})->name('download-invoice');

历史与未来付款

你可以使用 lastPayment 和 nextPayment 方法来获取并显示客户定期订阅的历史或未来付款:

use App\Models\User;

$user = User::find(1);

$subscription = $user->subscription();

$lastPayment = $subscription->lastPayment();
$nextPayment = $subscription->nextPayment();

这两个方法都会返回 Laravel\Paddle\Payment 的实例;不过,当交易尚未通过 webhook 同步时,lastPayment 会返回 null,而当计费周期结束时(例如订阅已取消),nextPayment 会返回 null:

下次付款:{{ $nextPayment->amount() }},到期日期为 {{ $nextPayment->date()->format('d/m/Y') }}

测试

在测试过程中,你应该手动测试你的计费流程,以确保集成按预期工作。

对于自动化测试,包括在 CI 环境中执行的测试,你可以使用Laravel 的 HTTP Client来模拟发往 Paddle 的 HTTP 调用。虽然这样无法测试 Paddle 的实际响应,但它提供了一种无需实际调用 Paddle API 即可测试应用程序的方法。