如何使用 PayPal 实现循环扣款功能?
背景
为满足业务需求,我们需要集成 PayPal 实现循环扣款功能。在查阅了各种资源后,发现除了官方网站未找到相关开发教程。经过两天的探索和调试,最终成功集成。本文将对如何使用 PayPal 的支付接口进行总结。
PayPal 接口概述
PayPal 目前提供多种接口:
- 通过 Braintree 实现 Express Checkout(后续将展开说明);
- 创建 App,通过 REST API 接口方式(现在的主流接口);
- 使用 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 帐号,并正确配置以下内容:
Client ID
Client Secret
Webhook API
(必须以https
开头且端口为443
,本地调试建议使用ngrok
生成地址)Returnurl
(同上,需注意)
完成 Wiki 中的首个示例后,理解接口的分类将对满足业务需求有所帮助。以下是接口的分类介绍(请结合示例理解):
- Payments:一次性支付接口,不支持循环扣款。主要支付内容包括支持 PayPal 支付、信用卡支付等。
- Payouts:未使用,略过。
- Authorization and Capture:支持用户通过 PayPal 帐号登录你的网站并获取相关信息;
- Sale、Order:与商城相关,未使用,略过;
- Billing Plan & Agreements:实现循环扣款的关键功能,这是本文的重点;
- Vault:存储信用卡信息;
- Payment Experience、Notifications、Invoice 等:不在本文讨论范围内。
循环扣款的实现步骤
实现循环扣款分为四个步骤:
- 创建并激活升级计划;
- 创建订阅(Agreement),引导用户跳转到 PayPal 网站进行同意;
- 用户同意后,执行订阅;
- 获取扣款账单。
1. 创建升级计划
升级计划对应 Plan 类。这一步需要注意:
- 升级计划创建后,默认处于
CREATED
状态,需将状态修改为ACTIVE
才能正常使用; - Plan 需包含
PaymentDefinition
和MerchantPreferences
两个对象,不能为空; - 如需创建
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_date
与end_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 接口文档。
注意事项
在实现功能的过程中,需考虑以下问题:
- 国内测试 Sandbox 时连接速度较慢,需考虑用户在执行中途可能关闭页面的情况;
- 一定要实现 Webhook,否则当用户在 PayPal 取消订阅时,你的网站无法接收到通知;
- 一旦生成订阅(Agreement),除非主动取消,否则将一直生效。若有多个升级计划设计,用户在切换计划时,需先取消前一个计划;
- 用户同意订阅的流程应当是原子操作,并尽量使其放到队列中执行,以改善用户体验。