为了满足业务需求,我们需要集成 PayPal 的循环扣款功能。在经过一番搜索后,除了官网外,未能找到相关的开发教程。最终,我在 PayPal 的文档上花费了两天时间,成功实现了集成。以下是我对如何使用 PayPal 支付接口的总结。
PayPal 目前提供多种接口:
通过 Braintree 实现 Express Checkout(后文将详细介绍 Braintree)。
创建应用,通过 REST API 接口(当前主流接口方式)。
NVP/SOAP API 接口(旧接口)。
Braintree 是 PayPal 收购的一家公司,除了支持 PayPal 支付外,还提供了升级计划、信用卡管理和客户信息等一系列功能,使用起来更加方便。虽然 PayPal 的 REST 接口也集成了大部分功能,但 PayPal 的 Dashboard 无法直接管理这些信息,而 Braintree 可以。因此,我更倾向于使用 Braintree。由于我使用的后端框架是 Laravel,其 cashier 解决方案默认支持 Braintree,因此这套接口是我的首选。然而,当我实现了所有功能后,发现一个问题:Braintree 在国内不支持。
REST API 是顺应时代发展的产物。如果你之前使用过 OAuth 2.0 和 REST API,那么理解这些接口应该不会有太大困难。
除非 REST API 接口无法满足需求(如政策限制),否则不推荐使用。全球都在向 OAuth 2.0 认证方式和 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 接口)。
Payouts:未使用,忽略。
Authorization and Capture:支持用户通过 PayPal 账号登录你的网站并获取相关信息。
Sale:与商城相关,未使用,忽略。
Order:与商城相关,未使用,忽略。
Billing Plan & Agreements:升级计划和签约,即实现循环扣款的功能,这是本文的重点。
Vault:存储信用卡信息。
Payment Experience:未使用,忽略。
Notifications:处理 Webhook 信息,重要但不是本文关注的内容。
Invoice:票据处理。
Identity:认证处理,实现 OAuth 2.0 登录,获取对应 token 以便请求其他 API,本文不再讨论。
实现循环扣款的步骤如下:
创建升级计划并激活。
创建订阅(Agreement),用户将跳转到 PayPal 网站等待同意。
用户同意后,执行订阅。
获取扣款账单。
升级计划对应 Plan 类。创建时需注意以下几点:
升级计划创建后处于 CREATED 状态,必须将状态修改为 ACTIVE 才能正常使用。
Plan 包含 PaymentDefinition 和 MerchantPreferences 两个对象,均不能为空。
如果想创建 TRIAL 类型的计划,必须有配套的 REGULAR 支付定义,否则会报错。
代码中调用的 setSetupFee 方法设置了首次扣款的费用,而 Agreement 对象的循环扣款方法设置的是第二次开始时的费用。
以下是创建一个 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;
}
创建 Agreement 以让用户订阅,注意以下几点:
setSetupFee 方法设置了首次扣款的费用,而 Agreement 对象的循环扣款方法设置的是第二次开始时的费用。
setStartDate 方法设置的是第二次扣款的时间,需使用 ISO8601 格式。
创建 Agreement 时,需提取 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());
// 添加计划 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 对象,因此请根据 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),用户在切换计划时,需取消前一个计划。
用户同意订阅的整个过程(取消旧订阅、完成新订阅的签约、修改用户信息为新的订阅)应视为原子操作,建议将其放入队列中执行,以提升用户体验。