Paypal实现循环扣款(订阅)功能


Posted in PHP onMarch 23, 2017

起因

业务需求要集成Paypal,实现循环扣款功能,然而百度和GOOGLE了一圈,除官网外,没找到相关开发教程,只好在Paypal上看,花了两天后集成成功,这里对如何使用Paypal的支付接口做下总结。

Paypal现在有多套接口:

  • 通过Braintree(后面会谈Braintree)实现Express Checkout;
  • 创建App,通过REST Api的接口方式(现在的主流接口方式);
  • NVP/SOAP API apps的接口(旧接口);

Braintree的接口

Braintree是Paypal收购的一家公司,它除了支持Paypal的支付外,还提供了升级计划,信用卡,客户信息等一系列全套的管理,使用上更方便;这些功能Paypal第二套REST接口其实也集成了大部分,但是Paypal的Dashboard不能直接管理这些信息而Braintree可以,所以我其实我更愿意用Braintree。关键是我使用的后端框架是Laravel,它的cashier解决方案默认可以支持Braintee,所以这套接口是我的首选。但是当我把它的功能都实现后发现一个蛋疼的问题:Braintree在国内不支持。。。。。。卒。。。

REST API

这是顺应时代发展的产物,如果你之前用过OAuth 2.0与REST API,那看这些接口应该不会有什么困惑。

旧接口

除非REST API接口有不能满足的,比如政策限制,否则不推荐使用。全世界都在往OAuth 2.0的认证方式和REST API的API使用方式迁移,干嘛逆势而行呢。因此在REST API能解决问题情况下,我也没对这套接口做深入比较。

REST API的介绍

官方的API参考文档https://developer.paypal.com/webapps/developer/docs/api/对于其API和使用方式有较详细的介绍,但是如果自己直接调这些API还是很繁琐的,同时我们只想尽快完成业务要求而不是陷入对API的深入了解。

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

在完成首个例子之前,请确保你有Sandbox帐号,并正确配置了:

  • Client ID
  • Client Secret
  • Webhook API(必须是https开头且是443端口,本地调试建议结合ngrok反向代理生成地址)
  • Returnurl(注意项同上)

在完成Wiki的首个例子后,理解下接口的分类有助于完成你的业务需求,下面我对接口分类做个介绍,请结合例子理解http://paypal.github.io/PayPal-PHP-SDK/sample/#payments。

  • 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已经做进去,本文也不谈。

如何实现循环扣款

分四个步骤:

  1. 创建升级计划,并激活;
  2. 创建订阅(创建Agreement),然后将跳转到Paypal的网站等待用户同意;
  3. 用户同意后,执行订阅
  4. 获取扣款帐单

1.创建升级计划

升级计划对应Plan这个类。这一步有几个注意点:

  • 升级计划创建后,处于CREATED状态,必须将状态修改为ACTIVE才能正常使用。
  • Plan有PaymentDefinition和MerchantPreferences两个对象,这两个对象都不能为空;
  • 如果想创建TRIAL类型的计划,该计划还必须有配套的REGULAR的支付定义,否则会报错;
  • 看代码有调用一个setSetupFee(非常,非常,非常重要)方法,该方法设置了完成订阅后首次扣款的费用,而Agreement对象的循环扣款方法设置的是第2次开始时的费用。

以创建一个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"
 ];

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

//上面的$param例子是个数组,我的实际应用传入的实际是个对象,用户理解下就好。
 public function createPlan($param)
 {
 $apiContext = $this->getApiContext();
 $plan = new Plan();
 // # Basic Information
 // Fill up the basic information that is required for the plan
 $plan->setName($param->name)
 ->setDescription($param->desc)
 ->setType('INFINITE');//例子总是设置为无限循环
 // # Payment definitions for this billing plan.
 $paymentDefinition = new PaymentDefinition();
 // The possible values for such setters are mentioned in the setter method documentation.
 // Just open the class file. e.g. lib/PayPal/Api/PaymentDefinition.php and look for setFrequency method.
 // You should be able to see the acceptable values in the comments.
 $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)));
 // Charge Models
 $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);
 // For Sample Purposes Only.
 $request = clone $plan;
 // ### Create Plan
 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),然后将跳转到Paypal的网站等待用户同意

Plan创建后,要怎么让用户订阅呢,其实就是创建Agreement,关于Agreement,有以下注意点:

  • 正如前面所述,Plan对象的setSetupFee方法,设置了完成订阅后首次扣款的费用,而Agreement对象的循环扣款方法设置的是第2次开始时的费用。
  • setStartDate方法设置的是第2次扣款时的时间,因此如果你按月循环,应该是当前时间加一个月,同时该方法要求时间格式是ISO8601格式,使用Carbon库可轻松解决;
  • 在创建Agreement的时候,此时还没有生成唯一ID,于是我碰到了一点小困难:那就是当用户完成订阅的时候,我怎么知道这个订阅是哪个用户的?通过Agreement的getApprovalLink方法得到的URL,里面的token是唯一的,我通过提取该token作为识别方式,在用户完成订阅后替换成真正的ID。

例子参数如下:

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

代码如下:

public function createPayment($param)
 {
 $apiContext = $this->getApiContext();
 $agreement = new Agreement();
 $agreement->setName($param['name'])
 ->setDescription($param['desc'])
 ->setStartDate(Carbon::now()->addMonths(1)->toIso8601String());
 // Add Plan ID
 // Please note that the plan Id should be only set in this case.
 $plan = new Plan();
 $plan->setId($param['id']);
 $agreement->setPlan($plan);
 // Add Payer
 $payer = new Payer();
 $payer->setPaymentMethod('paypal');
 $agreement->setPayer($payer);
 // For Sample Purposes Only.
 $request = clone $agreement;
 // ### Create Agreement
 try {
 // Please note that as the agreement has not yet activated, we wont be receiving the ID just yet.
 $agreement = $agreement->create($apiContext);
 // ### Get redirect url
 // The API response provides the url that you must redirect
 // the buyer to. Retrieve the url from the $agreement->getApprovalLink()
 // method
 $approvalUrl = $agreement->getApprovalLink();
 } catch (Exception $ex) {
 return "create payment failed, please retry or contact the merchant.";
 }
 return $approvalUrl;//跳转到$approvalUrl,等待用户同意
 }

函数执行后返回$approvalUrl,记得通过redirect($approvalUrl)跳转到Paypal的网站等待用户支付。

用户同意后,执行订阅

用户同意后,订阅还未完成,必须执行Agreement的execute方法才算完成真正的订阅。这一步的注意点在于

  • 完成订阅后,并不等于扣款,可能会延迟几分钟;
  • 如果第一步的setSetupFee费用设置为0,则必须等到循环扣款的时间到了才会产生订单;

代码片段如下:

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 ull;
 return $agreement;
 }
 return null;
 }

获取交易记录

订阅后,可能不会立刻产生交易扣费的交易记录,如果为空则过几分钟再次尝试。本步骤注意点:

  • start_date与end_date不能为空
  • 实际测试时,该函数返回的对象不能总是返回空的JSON对象,因此如果有需要输出JSON,请根据AgreementTransactions的API说明,手动取出对应参数。
/** 获取交易记录
 * @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("get transactions failed" . $e->getMessage());
 return null;
 }
 return $result->getAgreementTransactionList() ;
 }

最后,Paypal官方当然也有对应的教程,不过是调用原生接口的,跟我上面流程不一样点在于只说了前3步,供有兴趣的参考:https://developer.paypal.com/docs/integration/direct/billing-plans-and-agreements/。

需要考虑的问题

功能是实现了,但是也发现不少注意点:

  • 国内使用Sandbox测试时连接特别慢,经常提示超时或出错,因此需要特别考虑执行中途用户关闭页面的情况;
  • 一定要实现webhook,否则当用户进Paypal取消订阅时,你的网站将得不到通知;
  • 订阅(Agreement)一旦产生,除非主动取消,否则将一直生效。因此如果你的网站设计了多个升级计划(比如Basic,Standard,Advanced),当用户已经订阅某个计划后,去切换升级计划时,开发上必须取消前一个升级计划;
  • 用户同意订阅-(取消旧订阅-完成新订阅的签约-修改用户信息为新的订阅),括号整个过程 应该是原子操作,同时耗时又长,因此应该将其放到队列中执行直到成功体验会更好。

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,同时也希望多多支持三水点靠木!

PHP 相关文章推荐
如何对PHP程序中的常见漏洞进行攻击
Oct 09 PHP
Linux下将excel数据导入到mssql数据库中的方法
Feb 08 PHP
在PHP中使用反射技术的架构插件使用说明
May 18 PHP
url decode problem 解决方法
Dec 26 PHP
PHP函数之日期时间函数date()使用详解
Sep 09 PHP
PHP防止注入攻击实例分析
Nov 03 PHP
php微信公众账号开发之前五个坑(一)
Sep 18 PHP
php array_slice 取出数组中的一段序列实例
Nov 04 PHP
thinkPHP5 ACL用户权限模块用法详解
May 10 PHP
PHP之认识(二)关于Traits的用法详解
Apr 11 PHP
解决laravel 出现ajax请求419(unknown status)的问题
Sep 03 PHP
PHP7 list() 函数修改
Mar 09 PHP
PHP+JQUERY操作JSON实例
Mar 23 #PHP
php实现多维数组排序的方法示例
Mar 23 #PHP
基于Codeigniter框架实现的student信息系统站点动态发布功能详解
Mar 23 #PHP
php PDO实现的事务回滚示例
Mar 23 #PHP
降低PHP Redis内存占用
Mar 23 #PHP
使用Codeigniter重写insert的方法(推荐)
Mar 23 #PHP
PHP如何读取由JavaScript设置的Cookie
Mar 22 #PHP
You might like
一个简洁的多级别论坛
2006/10/09 PHP
PHP 加密/解密函数 dencrypt(动态密文,带压缩功能,支持中文)
2009/01/30 PHP
php 代码优化的42条建议 推荐
2009/09/25 PHP
php抓取页面的几种方法详解
2013/06/17 PHP
三种php连接access数据库方法
2013/11/11 PHP
php跨域cookie共享使用方法
2014/02/20 PHP
给大家分享几个常用的PHP函数
2017/01/15 PHP
PHP时间相关常用函数用法示例
2020/06/03 PHP
将函数的实际参数转换成数组的方法
2010/01/25 Javascript
jQuery之$(document).ready()使用介绍
2012/04/05 Javascript
js图片闪动特效可以控制间隔时间如几分钟闪动一下
2014/08/12 Javascript
js 数据存储和DOM编程
2017/02/09 Javascript
jquery编写日期选择器
2017/03/16 Javascript
angularjs中的$eval方法详解
2017/04/24 Javascript
Jquery获取radio选中的值
2017/05/05 jQuery
微信小程序之页面跳转和参数传递的实现
2017/09/29 Javascript
基于Vue、Vuex、Vue-router实现的购物商城(原生切换动画)效果
2018/01/09 Javascript
webpack4的迁移的使用方法
2018/05/25 Javascript
vue2.x集成百度UEditor富文本编辑器的方法
2018/09/21 Javascript
JavaScript之Blob对象类型的具体使用方法
2019/11/29 Javascript
node.js使用yargs处理命令行参数操作示例
2020/02/11 Javascript
如何修改Vue打包后文件的接口地址配置的方法
2020/04/22 Javascript
vue中实现拖动调整左右两侧div的宽度的示例代码
2020/07/22 Javascript
python练习程序批量修改文件名
2014/01/16 Python
Python中 Lambda表达式全面解析
2016/11/28 Python
python kmeans聚类简单介绍和实现代码
2018/02/23 Python
pyqt5 禁止窗口最大化和禁止窗口拉伸的方法
2019/06/18 Python
bluepy 一款python封装的BLE利器简单介绍
2019/06/25 Python
python输出数学符号实例
2020/05/11 Python
Python如何使用27行代码绘制星星图
2020/07/20 Python
请用Java实现列出某个目录下的所有文件
2013/09/23 面试题
《满井游记》教学反思
2014/02/26 职场文书
工作会议方案
2014/05/21 职场文书
运动与健康自我评价
2015/03/09 职场文书
Python超简单容易上手的画图工具库推荐
2021/05/10 Python
JavaScript 反射学习技巧
2021/10/16 Javascript