php对接Paypal支付完整流程

8,704 阅读8分钟

多图预警!!! 先来看看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那些,到时更换成你们的数据即可。非常感谢各位能看到这里,有不懂或不足的欢迎指正,感谢各位。