起因
由于业务需求需要集成 PayPal 的循环扣款功能,经过在百度和 Google 上的搜索,除了官网外未能找到相关的开发教程。最终,我在 PayPal 的文档上花费了两天时间成功集成。以下是我对如何使用 PayPal 支付接口的总结。
PayPal 目前提供多种接口:
- 通过 Braintree 实现 Express Checkout(后文将详细介绍 Braintree)。
- 创建应用,通过 REST API 接口(当前主流接口方式)。
- NVP/SOAP API 接口(旧接口)。
Braintree 接口
Braintree 是 PayPal 收购的一家公司,除了支持 PayPal 支付外,还提供了升级计划、信用卡、客户信息等一系列管理功能,使用上更为方便。虽然 PayPal 的 REST 接口也集成了大部分功能,但 PayPal 的 Dashboard 无法直接管理这些信息,而 Braintree 可以。因此,我更倾向于使用 Braintree。尤其是我使用的后端框架是 Laravel,其 Cashier 解决方案默认支持 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 反向代理生成地址)
- Return URL(同上)
完成 Wiki 的第一个例子后,理解接口的分类有助于满足你的业务需求。以下是接口分类的介绍:
- Payments:一次性支付接口,不支持循环捐款。主要支持 PayPal 支付、信用卡支付及通过已保存的信用卡支付(需使用 Vault 接口,因 PCI 要求不允许一般网站采集信用卡敏感信息),支持付给第三方收款人。
- Payouts:未使用,忽略。
- Authorization and Capture:支持用户通过 PayPal 账号登录你的网站并获取相关信息。
- Sale:与商城相关,未使用,忽略。
- Order:与商城相关,未使用,忽略。
- Billing Plan & Agreements:升级计划和签约,即 订阅 功能,实现 循环扣款 必须使用此功能,这是本文的重点。
- Vault:存储信用卡信息。
- Payment Experience:未使用,忽略。
- Notifications:处理 Webhook 信息,重要但不是本文关注内容。
- Invoice:票据处理。
- Identity:认证处理,实现 OAuth 2.0 登录,获取对应 token 以便请求其他 API,此部分已在 PayPal-PHP-SDK 中实现,本文不再讨论。
如何实现循环扣款
实现循环扣款分为四个步骤:
- 创建升级计划并激活。
- 创建订阅(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)
->setFrequency($param->frequency)
->setFrequencyInterval((string)$param->frequency_interval)
->setCycles((string)$param->cycles)
->setAmount(new Currency(array('value' => $param->amount, 'currency' => $param->currency)));
// 收费模型
$chargeModel = new ChargeModel();
$chargeModel->setType('TAX')
->setAmount(new Currency(array('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(array('value' => $param->amount, 'currency' => 'USD')));
$plan->setPaymentDefinitions(array($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)
创建完 Plan 后,如何让用户订阅呢?其实就是创建 Agreement,关于 Agreement,需注意以下几点:
- setSetupFee 方法设置了完成订阅后的 首次扣款 费用,而 Agreement 对象的循环扣款方法设置的是 第2次 开始时的费用。
- setStartDate 方法设置的是 第2次 扣款的时间,因此如果按月循环,应该是当前时间加一个月,且该方法要求时间格式为 ISO8601 格式,使用 Carbon 库可轻松解决。
- 创建 Agreement 时尚未生成唯一 ID,因此当用户完成订阅时,需通过 Agreement 的 getApprovalLink 方法得到的 URL 中的 token 作为识别方式,在用户完成订阅后替换成真正的 ID。
以下是示例参数:
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());
// 添加计划 ID
$plan = new Plan();
$plan->setId($param['id']);
$agreement->setPlan($plan);
// 添加付款人
$payer = new Payer();
$payer->setPaymentMethod('paypal');
$agreement->setPayer($payer);
// 创建 Agreement
try {
$agreement = $agreement->create($apiContext);
$approvalUrl = $agreement->getApprovalLink();
} catch (Exception $ex) {
return "创建支付失败,请重试或联系商家。";
}
return $approvalUrl; // 跳转到 $approvalUrl,等待用户同意
}
函数执行后返回 $approvalUrl,记得通过 redirect($approvalUrl) 跳转到 PayPal 网站等待用户支付。
用户同意后,执行订阅
用户同意后,订阅尚未完成,必须执行 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;
}
获取交易记录
订阅后,可能不会立刻产生交易扣费的记录,如果为空则过几分钟再次尝试。本步骤需注意:
- start_date 与 end_date 不能为空。
- 实际测试时,该函数返回的对象可能不会总是返回空的 JSON 对象,因此如需输出 JSON,请根据 AgreementTransactions 的 API 说明,手动取出对应参数。
php
/* 获取交易记录
* @param $id subscription payment_id
* @warning 总是获取该 subscription 的所有记录
/
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)一旦产生,除非主动取消,否则将一直生效。因此如果你的网站设计了多个升级计划(如 Basic、Standard、Advanced),当用户已订阅某个计划后,切换升级计划时,开发上必须取消前一个升级计划。
- 用户同意订阅的整个过程(取消旧订阅、完成新订阅的签约、修改用户信息为新的订阅)应视为原子操作,且耗时较长,因此建议将其放入队列中执行,以提升用户体验。