如何在 PayPal 实现循环扣款(订阅)功能?

起因

由于业务需求需要集成 PayPal,实现循环扣款功能,经过多次在 百度Google 搜索,除了官方网站外,并未找到相关的开发教程。于是我花了两天时间深入了解并成功集成。本文将总结如何使用 PayPal 的支付接口。

PayPal 现有的接口方式:

  1. Braintree 接口:通过 Braintree 实现 Express Checkout,后面会详细讲解。

  2. REST API 接口:当前主流的接口方式。

  3. NVP/SOAP API 接口:较旧的接口方式,已不推荐使用。

Braintree 接口

Braintree 是 PayPal 收购的一家公司,除了支持 PayPal 支付外,它还提供了升级计划、信用卡和客户信息等全套管理功能。虽然 PayPalREST API 接口也支持大部分功能,但 Braintree 可以直接通过后台管理这些信息,而 PayPalDashboard 并不能。因此,个人推荐使用 Braintree,特别是我使用的后端框架 Laravel,其 Cashier 解决方案默认支持 Braintree,这使得集成更加方便。

然而,问题也随之而来:Braintree 在国内并不支持,这让我只能转向其他接口。

REST API 接口

REST API 是顺应时代发展的接口,如果你熟悉 OAuth 2.0REST API,使用起来应该不会有什么困惑。

旧接口

除非 REST API 无法满足特定需求(例如政策限制),否则不推荐使用旧的 NVP/SOAP API。全世界都在向 OAuth 2.0REST API 迁移,使用最新的接口将带来更好的体验和支持。

REST API 介绍

PayPal 提供了官方的 API 参考文档,详尽地介绍了 API 的使用方式。不过,如果直接使用这些 API 会比较繁琐,我们更希望快速完成业务需求。建议直接安装 PayPal-PHP-SDK,并通过其 Wiki 作为起点进行集成。

在完成示例之前,请确保已配置好以下内容:

  • Client ID

  • Client Secret

  • Webhook API(必须是 HTTPS 开头且使用 443 端口,本地调试可结合 ngrok

  • Return URL(确保符合要求)

接口分类

在了解了 REST API 后,理解接口分类对于完成业务需求非常重要。以下是一些常见的接口分类:

  • Payments:一次性支付接口,不支持循环扣款。主要支持 PayPal 支付、信用卡支付等。

  • Payouts:本项目中未使用。

  • Authorization and Capture:支持通过 PayPal 账号登录并获取相关信息。

  • Billing Plan & Agreements:用于实现 订阅 功能,支持循环扣款,这是本文的重点。

  • Vault:用于存储信用卡信息。

  • Notifications:处理 Webhook 信息。

  • Invoice:用于票据处理。

如何实现循环扣款

实现循环扣款功能需要四个步骤:

  1. 创建并激活升级计划;

  2. 创建订阅(Agreement),并跳转到 PayPal 网站等待用户同意;

  3. 用户同意后,执行订阅;

  4. 获取扣款账单。

1. 创建升级计划

升级计划对应 Plan 类。在创建时需要注意以下几点:

  • Plan 创建后默认为 CREATED 状态,必须修改为 ACTIVE 才能正常使用。

  • PaymentDefinitionMerchantPreferences 两个对象是必填项。

  • 如果计划为 TRIAL 类型,必须有配套的 REGULAR 支付定义,否则会报错。

以下是一个 Standard 计划的示例:

$param = [
    "name" => "standard_monthly",
    "display_name" => "Standard Plan",
    "desc" => "Standard Plan for one month",
    "type" => "REGULAR",
    "frequency" => "MONTH",
    "frequency_interval" => 1,
    "cycles" => 0,
    "amount" => 20,
    "currency" => "USD"
];

创建并激活计划的代码:

public function createPlan($param)
{
    $apiContext = $this->getApiContext();
    $plan = new Plan();

    $plan->setName($param->name)
        ->setDescription($param->desc)
        ->setType('INFINITE'); // 设置为无限循环

    $paymentDefinition = new PaymentDefinition();
    $paymentDefinition->setName($param->name)
        ->setType($param->type)
        ->setFrequency($param->frequency)
        ->setFrequencyInterval((string)$param->frequency_interval)
        ->setCycles((string)$param->cycles)
        ->setAmount(new Currency(['value' => $param->amount, 'currency' => $param->currency]));

    $chargeModel = new ChargeModel();
    $chargeModel->setType('TAX')
        ->setAmount(new Currency(['value' => 0, 'currency' => $param->currency]));

    $returnUrl = config('payment.returnurl');
    $merchantPreferences = new MerchantPreferences();
    $merchantPreferences->setReturnUrl("$returnUrl?success=true")
        ->setCancelUrl("$returnUrl?success=false")
        ->setAutoBillAmount("yes")
        ->setInitialFailAmountAction("CONTINUE")
        ->setMaxFailAttempts("0")
        ->setSetupFee(new Currency(['value' => $param->amount, 'currency' => 'USD']));

    $plan->setPaymentDefinitions([$paymentDefinition]);
    $plan->setMerchantPreferences($merchantPreferences);

    try {
        $output = $plan->create($apiContext);
    } catch (Exception $ex) {
        return false;
    }

    $patch = new Patch();
    $value = new PayPalModel('{"state":"ACTIVE"}');
    $patch->setOp('replace')
        ->setPath('/')
        ->setValue($value);

    $patchRequest = new PatchRequest();
    $patchRequest->addPatch($patch);

    $output->update($patchRequest, $apiContext);

    return $output;
}

2. 创建订阅(Agreement)

创建订阅的过程如下:

public function createPayment($param)
{
    $apiContext = $this->getApiContext();
    $agreement = new Agreement();

    $agreement->setName($param['name'])
        ->setDescription($param['desc'])
        ->setStartDate(Carbon::now()->addMonths(1)->toIso8601String());

    $plan = new Plan();
    $plan->setId($param['id']);
    $agreement->setPlan($plan);

    $payer = new Payer();
    $payer->setPaymentMethod('paypal');
    $agreement->setPayer($payer);

    try {
        $agreement = $agreement->create($apiContext);
        $approvalUrl = $agreement->getApprovalLink();
    } catch (Exception $ex) {
        return "创建支付失败,请重试或联系商家。";
    }

    return $approvalUrl; // 跳转到 PayPal 网站
}

3. 用户同意后,执行订阅

用户同意后,必须调用 Agreementexecute 方法才能完成订阅。

4. 获取交易记录

订阅后,可能不会立刻产生交易记录,若为空可稍后再次尝试。获取交易记录的代码如下:

public function transactions($id)
{
    $apiContext = $this->getApiContext();
    $params = ['start_date' => date('Y-m-d', strtotime('-15 years')), 'end_date' => date('Y-m-d', strtotime('+5 days'))];

    try {
        $result = Agreement::searchTransactions($id, $params, $apiContext);
    } catch (\Exception $e) {
        Log::error("获取交易记录失败:" . $e->getMessage());
        return null;
    }

    return $result->getAgreementTransactionList();
}

需要考虑的问题

实现功能后,仍有以下注意事项:

  1. 国内使用 Sandbox 测试时连接较慢,需考虑用户关闭页面的情况。

  2. 必须实现 Webhook,否则无法接收到用户取消订阅的通知。

  3. 用户在切换订阅计划时,必须取消之前的订阅。

  4. 订阅过程应当作为原子操作,以确保长时间的操作不会影响用户体验。

WildCard | 一分钟注册,轻松订阅海外线上服务

使用门槛极低,微信支付宝均可开通使用。支持开通各类海外平台:ChatGPT、Claude、Google Play、Apple Store、OpenAI、X、Patreon、MidJourney、Amazon、POE、Microsoft、Facebook、GitHub、Telegram、PayPal等各类海淘订阅平台。使用邀请码:ACCPAY,立享消费0手续费,减免开卡费用。