多图预警!!! 先来看看paypal支付的流程
流程其实就是,前端请求服务端支付接口,服务端构建支付并发给paypal服务器,paypal服务器会返回支付链接给服务端,服务端拿到paypal返回的支付链接后返回给前端,前端跳转到支付链接给用户确认支付,用户点击确认支付后返回到服务端设置的地址,随后,然后paypal会发送异步回调过来,到时做验签然后检验结果看是否完成,如果完成则修改订单状态。
一、前期准备: 先去paypal官网注册账号(账号有三个等级:开发者账号,个人账号,企业账号,沙箱模式是三种账号都有的,但有些功能是高等级的账号才有,具体区别看下图),然后paypal会有一个沙箱模式,在开发阶段我们可以使用沙箱账号来进行测试,主要步骤如下: 1.首先去官网注册一个paypal账号,因为我做项目的时候是客户注册好的一个企业账号,所以这一步我省略,企业账号是最高等级的账号。
2.注册完登录后,进入开发者中心(developer.paypal.com/)可以进入沙箱账号
默认会有两个测试账号,一个是买家账号(Personal)一个是卖家账号(Business),默认有5000美元,不过都是虚拟的,也可以手工修改余额,或者增加测试买家账号和卖家账号。
然后给两个账号设置密码,点击账号展开,然后点击view/edit account,会弹出账号信息框,里面可以设置密码等一堆属性。这里不多说
然后进入我的应用程序和证书(My Apps & Credentials)申请App
然后点击REST API apps栏目下面的Create App按钮,写进一个APP名称,然后选择一个测试账户作为此APP绑定的账号,如果你在上一步没有申请新的测试账号(也可以另外创建测试的卖家帐号和买家帐号的,比如创建一个美国地区的卖家帐号和一个日本地区的买家帐号),那么这里默认就是选择了卖家帐号。
打开创建的应用,可以看到应用的Client ID和Secret(注意后面会用到)
然后设置设置异步通知地址,拉至最下面,点击 Add Webhook 创建一个事件,输入回调地址 yoursite.com/payment/pay…, 把 Payments payment created 和 Payment sale completed 勾选,然后确认即可 PayPal 提供的事件类型有很多,PayPal-Checkout 只用到了 Payments payment created 和 Payment sale completed.
这里的异步回调地址是到时paypal异步回调你的项目接口地址,webhookId后面有用。
到这一步,准备工作就做好了。
下面开始写代码阶段
二、具体实现(don't bb show me code): 先说说我的环境,项目是用TP6开发,SDK采用的是官方的
1.在项目中安装扩展(注意,官方已经不推荐用这个sdk,具体可以看文档,但因为新skd是今年(2020)刚刚推出,资料还比较稀缺,所以未用新sdk)
$ composer require paypal/rest-api-sdk-php:* // 这里使用的最新版本
2.创建paypal配置文件
$ touch config/paypal.php
配置内容如下(沙箱和生产环境是两套配置)
return [
// paypal sandbox config
'sandbox' => [
'client_id' => env('PAYPAL_SANDBOX_CLIENT_ID', ''), // 开发者中心创建的app的client_id
'secret' => env('PAYPAL_SANDBOX_SECRET', ''), // 开发者中心创建的app的secret
'notify_web_hook_id' => env('PAYPAL_SANDBOX_NOTIFY_WEB_HOOK_ID', ''), // 全局回调的钩子id(可不填)就是开发者中心创建的app的notify_web_hook_id
'checkout_notify_web_hook_id' => env('PAYPAL_SANDBOX_NOTIFY_WEB_HOOK_ID',''), // 收银台回调的钩子id
'subscription_notify_web_hook_id' => env('PAYPAL_SANDBOX_SUBSCRIPTION_NOTIFY_WEB_HOOK_ID','') //订阅回调的钩子id
],
// paypal live config
'live' => [
'client_id' => env('PAYPAL_CLIENT_ID', ''),
'secret' => env('PAYPAL_SECRET', ''),
'notify_web_hook_id' => env('PAYPAL_NOTIFY_WEB_HOOK_ID', ''),
'checkout_notify_web_hook_id' => env('PAYPAL_CHECKOUT_NOTIFY_WEB_HOOK_ID', ''),
'subscription_notify_web_hook_id' => env('PAYPAL_SUBSCRIPTION_NOTIFY_WEB_HOOK_ID', ''),
]
];
创建一个支付类
<?php
namespace app\provider;
// Paypal服务类
use app\models\store\StoreOrder;
use app\models\store\StoreOrderCartInfo;
use app\models\user\UserAddress;
use EasyWeChat\OpenPlatform\VerifyTicket;
use PayPal\Api\Amount;
use PayPal\Api\Details;
use PayPal\Api\Item;
use PayPal\Api\ItemList;
use PayPal\Api\Payer;
use PayPal\Api\Payment;
use PayPal\Api\PaymentExecution;
use PayPal\Api\RedirectUrls;
use PayPal\Api\Transaction;
use PayPal\Api\VerifyWebhookSignature;
use PayPal\Auth\OAuthTokenCredential;
use PayPal\Exception\PayPalConnectionException;
use PayPal\Rest\ApiContext;
use think\exception\HttpException;
use think\facade\Log;
use think\Request;
class PayPalProvider
{
protected $config;
protected $notifyWebHookId;
public $apiContext;
const accept_url = 'https://xxx.com/api/pay/callback'; // 同步回调请求的
const Currency = 'USD';//币种:美元
public function __construct($config)
{
// 秘钥配置
$this->config = $config;
$this->notifyWebHookId = $this->config['web_hook_id'];
$this->apiContext = new ApiContext(
new OAuthTokenCredential(
$this->config['client_id'],
$this->config['secret']
)
);
$this->apiContext->setConfig([
'mode' => $this->config['mode'],
'log.LogEnabled' => true,
'log.FileName' => app()->getRootPath().'runtime/log/PayPal.log', // 记录日志
'log.LogLevel' => 'debug', // 在live上用info
'cache.enable'=> true,
]);
}
// 收银台支付
public function checkout()
{
$product = 'iphone6'; //商品名称
$sku = '黑色 128G'
$price = 10; //价钱
$shipping = 100; //运费
$description = '描述内容';
$paypal = $this->apiContext ;
$total = $price + $shipping;//总价
$payer = new Payer();
$payer->setPaymentMethod('paypal');
$item = new Item();
$item->setName($product) // 商品名称
->setSku($sku) // sku,如果没有可以不写
->setCurrency(self::Currency) // 币种
->setQuantity(1) // 数量
->setPrice($price); // 价格
$itemList = new ItemList();
$itemList->setItems([$item]); // 现在只有一个商品,所以是[$item],如果两个,就是[$item, $item2],很明显可以想到,如果是多商品,可以做一个循环来构建一个数组,数组有多个item,然后传过来
$details = new Details();
$details->setShipping($shipping)
->setSubtotal($price); // setSubtotal要注意,这里是商品的总价格,不包含运费!!,比如商品A是10元,B是20元,运费是100元,这个setSubtotal要传的是10+20=30
$amount = new Amount();
$amount->setCurrency(self::Currency)
->setTotal($total) // 这个就是总价,即商品总价格+运费价格
->setDetails($details);
//创建交易
$transaction = new Transaction();
$transaction->setAmount($amount)
->setItemList($itemList)
->setDescription($description)
->setInvoiceNumber(uniqid()); //这里要注意:现在是测试代码,所以是uniqid(),真实情况要传你自己的唯一订单id,到时他异步回调的时候会把这个传过来,你就可以拿这个去修改订单状态
$redirectUrls = new RedirectUrls(); // 这里设置支付成功和失败后的跳转链接(同步回调)
$redirectUrls->setReturnUrl(self::accept_url . '?success=true')->setCancelUrl(self::accept_url . '/?success=false');
$payment = new Payment();
$payment->setIntent('sale')->setPayer($payer)->setRedirectUrls($redirectUrls)->setTransactions([$transaction]);
try {
$payment->create($this->apiContext);
// 得到支付链接
return $payment->getApprovalLink();
} catch (PayPalConnectionException $e){
$message = (string)$e->getData();
Log::write('PayPal Checkout Create Failed'.$message, 'error');
return null;
}
}
// 执行付款
public function executePayment($paymentId)
{
try{
$payment = Payment::get($paymentId, $this->apiContext);
$execution = new PaymentExecution();
$execution->setPayerId($payment->getPayer()->getPayerInfo()->getPayerId());
// 执行付款
$payment->execute($execution, $this->apiContext);
return $payment::get($payment->getId(), $this->apiContext);
} catch (PayPalConnectionException $e){
return false;
}
}
// 异步回调验签
public function verify(Request $request, $webHookId = null)
{
try {
$headers = $request->header();
$headers = array_change_key_case($headers, CASE_UPPER);
$content = $request->getContent();
Log::write($headers, 'debug');
// 如果是laravel,这里获请求头的方法可能要变,现在是$headers['PAYPAL-AUTH-ALGO'],去到laravel的话可能要$headers['PAYPAL-AUTH-ALGO'][0],到时试试就知道了,实在不行打日志看看数据结构再确定如何获取
$signatureVerification = new VerifyWebhookSignature();
$signatureVerification->setAuthAlgo($headers['PAYPAL-AUTH-ALGO']);
$signatureVerification->setTransmissionId($headers['PAYPAL-TRANSMISSION-ID']);
$signatureVerification->setCertUrl($headers['PAYPAL-CERT-URL']);
$signatureVerification->setWebhookId($webHookId ? : $this->notifyWebHookId);
$signatureVerification->setTransmissionSig($headers['PAYPAL-TRANSMISSION-SIG']);
$signatureVerification->setTransmissionTime($headers['PAYPAL-TRANSMISSION-TIME']);
$signatureVerification->setRequestBody($content);
$result = clone $signatureVerification;
$output = $signatureVerification->post($this->apiContext);
if($output->getVerificationStatus() == 'SUCCESS'){
return $result;
}
throw new HttpException(400, 'Verify Failed.');
} catch (HttpException $exception) {
Log::write('PayPal Notification Verify Failed'.$exception->getMessage(), 'error');
return false;
}
}
}
创建一个服务类
php think make:service PayPalService
代码如下
<?php
declare (strict_types = 1);
namespace app\service;
use app\provider\PayPalProvider;
class PayPalService extends \think\Service
{
/**
* 注册服务
*
* @return mixed
*/
public function register()
{
// 绑定paypal服务类
$this->app->bind('paypal', function () {
// 测试环境
$config = [
'mode' => 'sandbox',
'client_id' => config('paypal.sandbox.client_id'),
'secret' => config('paypal.sandbox.secret'),
'web_hook_id' => config('paypal.sandbox.notify_web_hook_id'),
];
// 线上环境
// $config = [
// 'mode' => 'live',
// 'client_id' => config('paypal.live.client_id'),
// 'secret' => config('paypal.live.secret'),
// 'web_hook_id' => config('paypal.live.notify_web_hook_id'),
// ];
return new PayPalProvider($config);
});
}
/**
* 执行服务
*
* @return mixed
*/
public function boot()
{
//
}
}
在service.php注册服务
<?php
return [
\app\AppService::class,
\app\service\PayPalService::class,
];
创建一个PayController控制器
<?php
namespace app\api\controller\store;
use app\models\store\StoreOrder;
use app\models\store\StoreOrderCartInfo;
use app\models\user\UserAddress;
use think\facade\Log;
use think\Request;
class PayController
{
// 订单支付
public function pay(Request $request)
{
$approvalUrl = app('paypal')->checkout();
if(!$approvalUrl){
return app('json')->fail('');
}else{
//返回支付链接给前端
return app('json')->successful(['url' => $approvalUrl]);
}
}
// 同步回调
public function callback(Request $request)
{
if($request->has('success') && $request->param('success') == 'true'){
// 执行付款
$payment = app('paypal')->executePayment($request->param('paymentId'));
// TODO: 这里编写同步回调支付成功后的逻辑,我这里是跳转到前端的支付成功页面
return redirect('http://middleeastweb.heifeng.xin/orderDeal/payResult?id='.$orderId.'&total='.$total);
}else{
// TODO: 这里编写同步回调支付失败后的逻辑,我这里是跳转到前端的支付失败页面
return redirect('http://middleeastweb.heifeng.xin/orderDeal/payResult?type=fail&id='.$orderId.'&total='.$total);
}
}
// 异步回调
public function notify(Request $request)
{
$response = app('paypal')->verify($request, config('paypal.sandbox.notify_web_hook_id'));
if(!$response){
Log::write('验签失败');
return "fail";
}
$data = json_decode($response->request_body, true); // 是json字符串,要解成数组。
Log::write('验签后的数据', 'debug');
Log::write($data, 'debug');
$eventType = $data['event_type'];
$resourceState = $data['resource']['state'];
if($eventType == 'PAYMENT.SALE.COMPLETED' && strcasecmp($resourceState, 'completed') == 0) {
$paymentId = $data['resource']['parent_payment'];
if(!$paymentId) {
return "fail";
}
$order_id = $data['resource']['invoice_number']; // 订单id
// 支付完成后的逻辑
Log::write('异步回调成功', 'debug');
// TODO: 这里写具体的支付完成后的流程(如: 更新订单的付款时间、状态 & 增加商品销量 & 发送邮件业务 等)
}else{
return "fail";
}
}
}
所以整个流程就是,前端请求服务器的PayController的pay方法,pay方法会调用PayPalService服务类的checkout构建支付(注意我这里只是实例代码,所以商品名称,价格是写死的,到时灵活变通即可),用户去链接付款成功后(这里并没有完成支付),会跳转到我们设置的同步回调地址,我这里就是PayController的callback方法(注意,paypal跳转到callback方法的时候,会再请求一次paypal完成支付(这里才完成支付),支付完后买家和卖家账号会看到交易信息)。 然后我们一开始在paypal开发者中心创建了一个app,并且在那个app上设置了异步回调地址,这个回调地址就是访问到了我们的PayController的notify方法,到时paypal会请求到这个接口,这里我建议把路由设置成any,即不管是get还是post还是其他都能访问的。 如果是laravel的话,因为laravel的CSRF机制,我们还要在相应的中间件中将路由放入到白名单才能被paypal访问。
现在来看看效果,请求付款接口,返回链接,把链接复制上浏览器,大力敲回车(注意要大力点)
如果你没有登录,会看到这个页面,注意这里是使用沙箱的测试买家账号登录,不要用卖家账号登录,而且因为我的测试买家的地址设置在了美国,所以是英文,如果你创建的买家账号是日本的,这里就会显示日文
登录完后显示,点击确认支付
成功执行我的同步回调逻辑
查看我们的日志,看看paypal有没有回调过来,并且有没有验签成功
回调成功(注意,我这边日志打到“异步回调成功的时候,意味着验签也成功了”),接下来登录(www.sandbox.paypal.com/)测试买家账号和卖家账…
先登录买家账号,发现款项已经扣除
登录卖家账号,已经打款过来
至此,整个paypal支付的流程就跑通了
遇到的坑: 在真实的环境中,paypal规定卖家的邮箱要先激活才能收款,而且如果是第一次收款的话,要先点一个确认收款款项才能加上去,但是我一开始以为沙盒环境的卖家账号是不需要激活的,所以遇到了流程都能走通,买家也扣款了,但是卖家那边的款项一直没加上去,百思不得其解,差点就怀疑人生了,后来发现原来测试卖家账号也是需要激活才能收到款。 附上沙箱卖家账号激活流程:
登录沙箱卖家账号,鼠标指向右上角的账户名下拉会弹出一个Profile Settings,点击进去。登录和安全下有个邮箱地址,点击更新,然后 发送邮箱验证。这时候登录你的真实paypal账号developer.paypal.com,左边SANDBOX下有个notifications,点击进去会看到有一个验证邮箱的通知,点击进去验证就可以
如果是真实的账号的话,激活则在
我现在已经是激活状态,所以显示这个,如果没激活的话,他会有提示叫你去激活邮箱
至此整个paypal流程就已经走通了,因为方便读者们看所以有些数据我是写死了,比如商品信息那里的iphone那些,到时更换成你们的数据即可。非常感谢各位能看到这里,有不懂或不足的欢迎指正,感谢各位。