如何使用 PayPal 实现循环扣款功能?

背景

为满足业务需求,我们需要集成 PayPal 实现循环扣款功能。在查阅了各种资源后,发现除了官方网站未找到相关开发教程。经过两天的探索和调试,最终成功集成。本文将对如何使用 PayPal 的支付接口进行总结。

PayPal 接口概述

PayPal 目前提供多种接口:

  1. 通过 Braintree 实现 Express Checkout(后续将展开说明);
  2. 创建 App,通过 REST API 接口方式(现在的主流接口);
  3. 使用 NVP/SOAP API 接口(旧接口)。

Braintree 接口

Braintree 是 PayPal 收购的公司,除了支持 PayPal 支付外,还提供了一系列功能,如升级计划、信用卡管理等,用户体验更佳。虽然这些功能在 PayPal 的第二套 REST 接口中也有集成,但 PayPal 的 Dashboard 不能直接进行管理。因此,我选择使用 Braintree 接口。然而,令人失望的是,Braintree 在国内并不支持

REST API

REST API 是顺应时代发展的产物。如果您之前使用过 OAuth 2.0 和 REST API,那么这些接口的使用应该不会感到困惑。

旧接口

除非 REST API 接口不能满足需求,否则不推荐使用。当前全球发展趋势都在向 OAuth 2.0 和 REST API 接口迁移,为何逆流而行呢?因此在 REST API 能解决问题的前提下,我并未深入比较旧接口。

REST API 简介

官方的 API 参考文档 PayPal API 文档 对 API 及其使用方式有详细介绍。然而,直接调试这些 API 仍显繁琐,我们希望能够尽快完成业务需求,而不是深入了解 API。

如何开始呢? 建议直接安装官方提供的 PayPal-PHP-SDK,并通过其 Wiki 作为起点。

在完成首个示例之前,请确保您拥有一个 Sandbox 帐号,并正确配置以下内容:

  1. Client ID
  2. Client Secret
  3. Webhook API(必须以 https 开头且端口为 443,本地调试建议使用 ngrok 生成地址)
  4. Returnurl(同上,需注意)

完成 Wiki 中的首个示例后,理解接口的分类将对满足业务需求有所帮助。以下是接口的分类介绍(请结合示例理解):

  • Payments:一次性支付接口,不支持循环扣款。主要支付内容包括支持 PayPal 支付、信用卡支付等。
  • Payouts:未使用,略过。
  • Authorization and Capture:支持用户通过 PayPal 帐号登录你的网站并获取相关信息;
  • SaleOrder:与商城相关,未使用,略过;
  • Billing Plan & Agreements:实现循环扣款的关键功能,这是本文的重点;
  • Vault:存储信用卡信息;
  • Payment ExperienceNotificationsInvoice 等:不在本文讨论范围内。

循环扣款的实现步骤

实现循环扣款分为四个步骤:

  1. 创建并激活升级计划;
  2. 创建订阅(Agreement),引导用户跳转到 PayPal 网站进行同意;
  3. 用户同意后,执行订阅;
  4. 获取扣款账单。

1. 创建升级计划

升级计划对应 Plan 类。这一步需要注意:

  • 升级计划创建后,默认处于 CREATED 状态,需将状态修改为 ACTIVE 才能正常使用;
  • Plan 需包含 PaymentDefinitionMerchantPreferences 两个对象,不能为空;
  • 如需创建 TRIAL 类型的计划,则必须配套相关的 REGULAR 支付定义;
  • 需注意调用 setSetupFee 方法,该方法设置了完成订阅后 首次扣款 的费用,而 Agreement 对象的循环扣款方法则设置的是 第2次 开始时的费用。

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

php
$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"
];

创建并激活计划的代码如下:

php
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)
                  . . . // 省略其他设置
                  ;

// 设置商户偏好
$merchantPreferences = new MerchantPreferences();
$merchantPreferences->setReturnUrl($returnUrl)
                    . . . // 省略其他设置
                    ;

$plan->setPaymentDefinitions(array($paymentDefinition));
$plan->setMerchantPreferences($merchantPreferences);

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

// 修改状态为 ACTIVE
$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)

创建 Plan 后,接下来需要创建 Agreement,通过 Agreement 来管理用户的订阅。需要注意以下几点:

  • Agreement 的 setStartDate 方法设置 第2次 扣款的时间,若按月循环,则应为当前时间加一个月,并符合 ISO8601 格式。
  • 在创建 Agreement 之前并未生成唯一 ID,因此可通过 getApprovalLink 方法获取的 URL 中提取 token 进行识别。

示例参数如下:

php
$param = [
'id' => 'P-26T36113JT475352643KGIHY', // 上一步创建的 Plan ID
'name' => 'Standard',
'desc' => 'Standard Plan for one month'
];

代码示例:

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

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

// 添加 Plan ID
$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; // 跳转到 $approvalUrl,等待用户同意

}

3. 用户同意后执行订阅

用户同意后,必须执行 Agreement 的 execute 方法,方可算作完成真正的订阅。注意:

  • 完成订阅并不等于立即扣款,可能会延迟几分钟;
  • 如果 setSetupFee 设置为0,则需等循环扣款时间到达后才会产生订单。

代码如下:

php
public function onPay($request)
{
$apiContext = $this->getApiContext();
if ($request->has('success') && $request->success == 'true') {
$token = $request->token;
$agreement = new \PayPal\Api\Agreement();
try {
$agreement->execute($token, $apiContext);
} catch(\Exception $e) {
return null;
}
return $agreement;
}
return null;
}

4. 获取交易记录

订阅后,可能不会立即产生交易扣费的记录。如果发现记录为空,可以稍后再次尝试。获取交易记录时,需注意:

  • start_dateend_date 不能为空;
  • 有时返回的对象可能为空,因此需根据 AgreementTransactions 的 API 说明手动提取相应参数。

示例代码:

php
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();
}

参考资料

PayPal 官方也有对应的教程,不过调用原生接口的简单流程略有不同。对此有兴趣的读者可以查看:PayPal 接口文档

注意事项

在实现功能的过程中,需考虑以下问题:

  1. 国内测试 Sandbox 时连接速度较慢,需考虑用户在执行中途可能关闭页面的情况;
  2. 一定要实现 Webhook,否则当用户在 PayPal 取消订阅时,你的网站无法接收到通知;
  3. 一旦生成订阅(Agreement),除非主动取消,否则将一直生效。若有多个升级计划设计,用户在切换计划时,需先取消前一个计划;
  4. 用户同意订阅的流程应当是原子操作,并尽量使其放到队列中执行,以改善用户体验。

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

THE END