PayPal 支付-Checkout 收银台和 Subscription 订阅计划全过程分享

废话不多说, 我们先从请求的生命周期来分析, 逐步实现整个过程.

一. 生命周期

1. Checkout - 收银台支付

拆解流程如图所示(过程类似支付宝的收银台):
PayPal 支付-Checkout 收银台和 Subscription 订阅计划全过程分享

流程详解:

  1. 本地应用组装好参数并请求Checkout接口, 接口同步返回一个支付URL;
  2. 本地应用重定向至这个URL, 登陆PayPal账户并确认支付, 用户支付后跳转至设置好的本地应用地址;
  3. 本地请求PayPal执行付款接口发起扣款;
  4. PayPal发送异步通知至本地应用, 本地拿到数据包后进行验签操作;
  5. 验签成功则进行支付完成后的业务(修改本地订单状态、增加销量、发送邮件等).

2. Subscription - 订阅支付

拆解流程:

PayPal 支付-Checkout 收银台和 Subscription 订阅计划全过程分享

流程详解:

  1. 创建一个计划;

  2. 激活该计划;

  3. 用已经激活的计划去创建一个订阅申请;

  4. 本地跳转至订阅申请链接获取用户授权并完成第一期付款, 用户支付后携带token跳转至设置好的本地应用地址;

  5. 回跳后请求执行订阅;

  6. 收到订阅授权异步回调结果, 收到支付结果的异步回调, 验证支付异步回调成功则进行支付完成后的业务.

二. 具体实现

了解了以上流程, 接下来开始Coding.

github上有很多SDK, 这里使用的是官方的SDK.

Checkout

在项目中安装扩展

$ composer require paypal/rest-api-sdk-php:* // 这里使用的最新版本

创建paypal配置文件

$ touch config/paypal.php

配置内容如下(沙箱和生产两套配置):

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | PayPal sandbox config
    |--------------------------------------------------------------------------
    |
    |
    */

    'sandbox' => [
        'client_id' => env('PAYPAL_SANDBOX_CLIENT_ID', ''),
        'secret' => env('PAYPAL_SANDBOX_SECRET', ''),
        'notify_web_hook_id' => env('PAYPAL_SANDBOX_NOTIFY_WEB_HOOK_ID', ''), // 全局回调的钩子id(可不填)
        'checkout_notify_web_hook_id' => env('PAYPAL_SANDBOX_CHECKOUT_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', ''),
    ],

];

创建一个PayPal服务类

$ mkdir -p app/Services && touch app/Services/PayPalService.php

编写Checkout的方法

可以参考官方给的DEMO

<?php

namespace App\Services;

use App\Models\Order;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use PayPal\Api\Currency;
use PayPal\Auth\OAuthTokenCredential;
use PayPal\Rest\ApiContext;
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\RedirectUrls;
use PayPal\Api\Transaction;
use PayPal\Api\PaymentExecution;
use Symfony\Component\HttpKernel\Exception\HttpException;

class PayPalService
{
    /*
     * array
     */
    protected $config;

    /*
     * string
     */
    protected $notifyWebHookId;

    /*
     * obj ApiContext
     */
    public $apiContext;

    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' => storage_path('logs/PayPal.log'),
            'log.LogLevel' => 'DEBUG', // PLEASE USE `INFO` LEVEL FOR LOGGING IN LIVE ENVIRONMENTS
            'cache.enabled' => true,
        ]);

    }

    /**
     * @Des 收银台支付
     * @Author Mars
     * @param Order $order
     * @return string|null
     */
    public function checkout(Order $order)
    {
        try {
            $payer = new Payer();
            $payer->setPaymentMethod('paypal');

            $item = new Item();
            $item->setName($order->product->title) // 子订单的名称
                ->setDescription($order->no) // 子订单描述
                ->setCurrency($order->product->currency) // 币种
                ->setQuantity(1) // 数量
                ->setPrice($order->total_amount); // 价格

            $itemList = new ItemList();
            $itemList->setItems([$item]); // 设置子订单列表

            // 这里是设置运费等
            $details = new Details();
            $details->setShipping(0)
                ->setSubtotal($order->total_amount);
            // 设置总计费用
            $amount = new Amount();
            $amount->setCurrency($order->product->currency)
                ->setTotal($order->total_amount)
                ->setDetails($details);
            // 创建交易
            $transaction = new Transaction();
            $transaction->setAmount($amount)
                ->setItemList($itemList)
                ->setDescription($order->no)
                ->setInvoiceNumber(uniqid());

            // 这里设置支付成功和失败后的跳转链接
            $redirectUrls = new RedirectUrls();
            $redirectUrls->setReturnUrl(route('payment.paypal.return', ['success' => 'true', 'no' => $order->no]))
                ->setCancelUrl(route('payment.paypal.return', ['success' => 'false', 'no' => $order->no]));

            $payment = new Payment();
            $payment->setIntent('sale')
                ->setPayer($payer)
                ->setRedirectUrls($redirectUrls)
                ->setTransactions([$transaction]);

            $payment->create($this->apiContext);

            // 得到支付链接
            return $payment->getApprovalLink();
        } catch (HttpException $e) {
            Log::error('PayPal Checkout Create Failed', ['msg' => $e->getMessage(), 'code' => $e->getStatusCode(), 'data' => ['order' => ['no' => $order->no]]]);

            return null;
        }
    }

    /**
     * @Des 执行付款
     * @Author Mars
     * @param Payment $payment
     * @return bool|Payment
     */
    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 (HttpException $e) {
            return false;
        }
    }

将PayPal服务类注册在容器中

打开文件 app/Providers/AppServiceProvider.php

<?php
  namespace App\Providers;
  .
  .
  .
  use App\Services\PayPalService;

  class AppServiceProvider extends ServiceProvider
  {
    public function register()
    {
       .
       .
       .

        // 注册PayPalService开始
        $this->app->singleton('paypal', function () {
            // 测试环境
            if (app()->environment() !== 'production') {
                $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'),
                ];
            } 
            // 生产环境
            else {
                $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 PayPalService($config);
        });
      // 注册PayPalService结束
    }

    .
    .
    .

创建控制器

由于订单系统要视具体业务需求, 在这里就不赘述了. 下面直接根据订单去直接请求checkout支付

$ php artisan make:controller PaymentsController

<?php

namespace App\Http\Controllers;

use App\Models\Order;

class PaymentController extends Controller
{
    /**
     * @Des PayPal-Checkout
     * @Author Mars
     * @param Order $order
     */
    public function payByPayPalCheckout(Order $order)
    {
        // 判断订单状态
        if ($order->paid_at || $order->closed) {
            return json_encode(['code' => 422, 'msg' => 'Order Status Error.', 'url' => '']);
        }
        // 得到支付的链接
        $approvalUrl = app('paypal')->checkout($order);
        if (!$approvalUrl) {
            return json_encode(['code' => 500, 'msg' => 'Interval Error.', 'url' => '']);
        }
        // 支付链接
        return json_encode(['code' => 201, 'msg' => 'success.', 'url' => $approvalUrl]);
    }
}

支付完的回跳方法

app/Http/Controllers/PaymentController.php

<?php
.
.
.
use Illuminate\Http\Request;
class PaymentController extends Controller
{
  .
  .
  .
  /**
   * @Des 支付完的回跳入口
   * @Author Mars
   * @param Request $request
   */
  public function payPalReturn(Request $request)
  {
      if ($request->has('success') && $request->success == 'true') {
        // 执行付款
        $payment = app('paypal')->executePayment($request->paymentId);

        // TODO: 这里编写支付后的具体业务(如: 跳转到订单详情等...)

      } else {
        // TODO: 这里编写失败后的业务

      }
  }
}

验签方法

在PayPalService中加入验签方法app/Services/PayPalService.php

<?php

namespace App\Services;
.
.
.

use PayPal\Api\VerifyWebhookSignature;

class PayPalService
{
  .
  .
  .
    /**
     * @des 回调验签
     * @author Mars
     * @param Request $request
     * @param $webHookId
     * @return VerifyWebhookSignature|bool
     */
    public function verify(Request $request, $webHookId = null)
    {
        try {
            $headers = $request->header();
            $headers = array_change_key_case($headers, CASE_UPPER);

            $content = $request->getContent();

            $signatureVerification = new VerifyWebhookSignature();
            $signatureVerification->setAuthAlgo($headers['PAYPAL-AUTH-ALGO'][0]);
            $signatureVerification->setTransmissionId($headers['PAYPAL-TRANSMISSION-ID'][0]);
            $signatureVerification->setCertUrl($headers['PAYPAL-CERT-URL'][0]);
            $signatureVerification->setWebhookId($webHookId ?: $this->notifyWebHookId);
            $signatureVerification->setTransmissionSig($headers['PAYPAL-TRANSMISSION-SIG'][0]);
            $signatureVerification->setTransmissionTime($headers['PAYPAL-TRANSMISSION-TIME'][0]);
            $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 $e) {
            Log::error('PayPal Notification Verify Failed', ['msg' => $e->getMessage(), 'code' => $e->getStatusCode(), 'data' => ['request' => ['header' => $headers, 'body' => $content]]]);
            return false;
        }
    }

}

异步回调

app/Http/Controllers/PaymentController.php

<?php
.
.
.
use Illuminate\Http\Request;
use Illuminate\Support\Arr;

class PaymentController extends Controller
{
    .
    .
    .

    /**
    * @des PayPal-Checkout-Notify
    * @author Mars
    * @param Request $request
    * @return string
    */
    public function payPalNotify(Request $request)
    {
      // 这里记录下日志, 本地测试回调时会用到
      Log::info('PayPal Checkout Notification', ['request' => ['header' => $request->header(), 'body' => $request->getContent()]]);

        $response = app('paypal')->verify($request, config('paypal.live.checkout_notify_web_hook_id'));
      // 验证失败
      if (!$response) {
            return 'fail';
      }

      // 回调包的请求体
      $data = json_decode($response->request_body, true);
      $eventType = Arr::get($data, 'event_type');
      $resourceState = Arr::get($data, 'resource.state');

      // 验证回调事件类型和状态
      if ($eventType == 'PAYMENT.SALE.COMPLETED' && strcasecmp($resourceState, 'completed') == 0) {
            $paymentId = Arr::get($data, 'resource.parent_payment');
            if (!$paymentId) {
                return 'fail';
            }
            // 订单
            $payment = app('paypal')->getPayment($paymentId);

            // 包中会有买家的信息
            $payerInfo = $payment->getPayer()->getPayerInfo();

            // TODO: 这里写具体的支付完成后的流程(如: 更新订单的付款时间、状态 & 增加商品销量 & 发送邮件业务 等)
            .
            .
            .

            return 'success';
      }
        return 'fail';
    }
}

创建路由

route/web.php

<?php
  .
  .
  .
  // PayPal-Checkout
  Route::get('payment/{order}/paypal', 'PaymentController@payByPayPalCheckout')
       ->name('payment.paypal_checkout');
  // PayPal-Checkout-Return
  Route::get('payment/paypal/return', 'PaymentController@payPalReturn')
        ->name('payment.paypal.return');
  // PayPal-Checkout-Notify
  Route::post('payment/paypal/notify', 'PaymentController@payPalNotify')
        ->name('payment.paypal.notify');

由于异步回调是POST请求, 因为Laravel的CSRF机制, 所以我们需要在相应的中间件中将其路由加入到白名单中才能被PayPal访问.

app/Http/MiddlewareVerifyCsrfToken.php

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
   .
   .
   .

    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array
     */
    protected $except = [
        // PayPal-Checkout-Notify
        'payment/paypal/notify',
    ];
}

设置PayPal-WebHookEvent

打开PayPal开发者中心进行配置

以沙箱环境为例, 生产一样
PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享
没有账号的新建一个, 如果有就点进去, 拉至最下面, 点击Add Webhook创建一个事件, 输入回调地址 https://yoursite.com/payment/paypal/notify, 把Payments payment createdPayment sale completed勾选, 然后确认即可.

PayPal的回调地址只支持HTTPS协议, 可以参考下Nginx官方给的配置HTTPS方法, 耐心照着步骤一步一步来很好配, 这里不做赘述.

PayPal提供的事件类型有很多, PayPal-Checkout只用到了Payments payment createdPayment sale completed.

PayPal 支付-Checkout 收银台和 Subscription 订阅计划全过程分享
PayPal 支付-Checkout 收银台和 Subscription 订阅计划全过程分享

配置完记得将Webhook ID添加到我们项目的配置中!

测试Checkout支付

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享
复制链接浏览器访问

登陆后进行支付. (这里不得不吐槽, 沙箱环境真的真的真的很慢很慢很慢...)

在开发者中心的沙箱环境中可以一键创建测试账号(支付用个人账号), 这里就不做演示了.

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享

从线上的日志中拿到数据包进行本地测试

请求头:

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享

在控制器中先打印验签结果app/Http/Controllers/PaymentController.php

<?php

namespace App\Http\Controllers;

use App\Events\OrderPaid;
use App\Models\Order;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;

class PaymentController extends Controller
{
    .
    .
    .

    public function payPalNotify(Request $request)
    {
        $response = app('paypal')->verify($request, config('paypal.sandbox.checkout_notify_web_hook_id'));
        dd($response);
            .
        .
        .
    }

}

打印结果如下, 接下来就可以编写支付成功后的业务代码了.

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享
至此, Checkout流程就结束了.

Subscription

创建计划并激活计划

以下方法均参考官方DEMO

app/Services/PayPalService.php

<?php
namespace App\Services;

.
.
.
use PayPal\Api\Plan;
use PayPal\Api\PaymentDefinition;
use PayPal\Api\ChargeModel;
use PayPal\Api\MerchantPreferences;
use PayPal\Api\Patch;
use PayPal\Common\PayPalModel;
use PayPal\Api\PatchRequest;
use PayPal\Api\Agreement;

class PayPalService
{
    .
    .
    .

    /**
     * @des 创建计划并激活计划
     * @author Mars
     * @param Order $order
     * @return Plan|false
     */
    public function createPlan(Order $order)
    {
        try {
            $plan = new Plan();
            $plan->setName($order->no)
                ->setDescription($order->product->title)
                ->setType('INFINITE'); // 可选(FIXED | INFINITE)

            $paymentDefinition = new PaymentDefinition();

            $paymentDefinition->setName('Regular Payments')
                ->setType('REGULAR')
                ->setFrequency('MONTH') // 设置频率, 可选(DAY | WEEK | MONTH | YEAR)
                // ->setFrequency('DAY')
                ->setFrequencyInterval($order->product->effective_months) // 设置频率间隔
                ->setCycles(0) // 设置周期(如果Plan的Type为FIXED的, 对应这里填99表示无限期. 或Plan的Type为INFINITE, 这里设置0)
                ->setAmount(new Currency([
                    'value' => $order->product->price, // 价格
                    'currency' => $order->product->currency // 币种
                ]));

            // Charge Models 这里可设置税和运费等
            $chargeModel = new ChargeModel();
            $chargeModel->setType('TAX')
                // ->setType('SHIPPING')
                ->setAmount(new Currency([
                    'value' => $order->product->tax ?? 0,
                    'currency' => $order->product->currency
                ]));

            $paymentDefinition->setChargeModels([$chargeModel]);

            $merchantPreferences = new MerchantPreferences();
                        // 这里设置支付成功和失败的回跳URL
            $merchantPreferences->setReturnUrl(route('subscriptions.paypal.return', ['success' => 'true', 'no' => $order->no]))
                ->setCancelUrl(route('subscriptions.paypal.return', ['success' => 'false', 'no' => $order->no]))
                ->setAutoBillAmount("yes")
                ->setInitialFailAmountAction("CONTINUE")
                ->setMaxFailAttempts("0")
                ->setSetupFee(new Currency([
                    'value' => $order->product->price, // 设置第一次订阅扣款金额***, 默认0表示不扣款
                    'currency' => $order->product->currency // 币种
                ]));

            $plan->setPaymentDefinitions([$paymentDefinition]);
            $plan->setMerchantPreferences($merchantPreferences);

            $output = $plan->create($this->apiContext);

            // 激活计划
            $patch = new Patch();

            $value = new PayPalModel('{"state":"ACTIVE"}');

            $patch->setOp('replace')
                ->setPath('/')
                ->setValue($value);
            $patchRequest = new PatchRequest();
            $patchRequest->addPatch($patch);

            $output->update($patchRequest, $this->apiContext);

            $result = Plan::get($output->getId(), $this->apiContext);
            if (!$result) {
                throw new HttpException(500, 'PayPal Interval Error.');
            }

            return $result;
        } catch (HttpException $e) {
            Log::error('PayPal Create Plan Failed', ['msg' => $e->getMessage(), 'code' => $e->getStatusCode(), 'data' => ['order' => ['no' => $order->no]]]);

            return false;
        }
    }

创建订阅申请

接上面的代码 ↑


   .
   .
   .

   /**
     * @des 创建订阅申请
     * @author Mars
     * @param Plan $param
     * @param Order $order
     * @return string|null
     */
    public function createAgreement(Plan $param, Order $order)
    {
        try {

            $agreement = new Agreement();

            $agreement->setName($param->getName())
                ->setDescription($param->getDescription())
                ->setStartDate(Carbon::now()->addMonths($order->product->effective_months)->toIso8601String()); // 设置下次扣款的时间, 测试的时候可以用下面的 ↓, 第二天扣款
                // ->setStartDate(Carbon::now()->addDays(1)->toIso8601String());

            $plan = new Plan();
            $plan->setId($param->getId());
            $agreement->setPlan($plan);

            $payer = new Payer();
            $payer->setPaymentMethod('paypal');
            $agreement->setPayer($payer);

            // $request = clone $agreement;

            // Please note that as the agreement has not yet activated, we wont be receiving the ID just yet.
            $agreement = $agreement->create($this->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();

            // 跳转到 $approvalUrl 等待用户同意
            return $approvalUrl;
        } catch (HttpException $e) {
            Log::error('PayPal Create Agreement Failed', ['msg' => $e->getMessage(), 'code' => $e->getStatusCode(), 'data' => ['plan' => $param]]);
            return null;
        }
    }

执行订阅

接上面 ↑

   .
   .
   .

   /**
     * @Des 执行订阅
     * @Date 2019-10-30
     * @Author Mars
     * @param $token
     * @return Agreement|bool
     */
    public function executeAgreement($token)
    {
        try {
            $agreement = new Agreement();

            $agreement->execute($token, $this->apiContext);

            return $agreement;
        } catch (HttpException $e) {
            Log::error('PayPal Execute Agreement Failed', ['msg' => $e->getMessage(), 'code' => $e->getStatusCode(), 'data' => ['token' => $token]]);
            return false;
        }
    }

控制器调用

这里为了跟Checkout区别开来, 我们新建一个专门负责订阅的控制器

$ php artisan make:controller SubscriptionsController

<?php

namespace App\Http\Controllers;

use App\Models\Order;

class SubscriptionsController extends Controller
{
   /**
     * @Des PayPal-CreatePlan
     * @Author Mars
     * @param Order $order
     */
    public function createPlan(Order $order)
    {
        if ($order->paid_at || $order->closed) {
            return json_encode(['code' => 422, 'msg' => 'Order Status Error.', 'url' => '']);
        }

        // 创建计划并升级计划
        $plan = app('paypal')->createPlan($order);
        if (!$plan) {
            return json_encode(['code' => 500, 'msg' => 'Create Plan Failed.', 'url' => ''])
        }

        // 创建订阅申请
        $approvalUrl = app('paypal')->createAgreement($plan, $order);
        if (!$approvalUrl) {
            return json_encode(['code' => 500, 'msg' => 'Create Agreement Failed.', 'url' => '']);
        }
        // 跳转至PayPal授权订阅申请的链接
        return json_encode(['code' => 201, 'msg' => 'success.', 'url' => $approvalUrl]);
    }

}

支付完的回跳方法

app/Http/Controllers/SubscriptionsController.php

<?php

namespace App\Http\Controllers;

.
.
.
use Carbon\Carbon;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;

class SubscriptionsController extends Controller
{  
    .
    .
    .

   /**
     * @Des 执行订阅
     * @Author Mars
     * @param Request $request
     * @return void|\Illuminate\View\View
     */
    public function executeAgreement(Request $request)
    {
        if ($request->has('success') && $request->success == 'true') {
            $token = $request->token;
            try {
                // 执行订阅
                // PayPal\Api\Agreement
                $agreement = app('paypal')->executeAgreement($token);

                if (!$agreement) {
                    throw new HttpException(500, 'Execute Agreement Failed');
                }
                // TODO: 这里写支付后的业务, 比如跳转至订单详情页或订阅成功页等
                .
                .
                .

                // 这里举例
                $order = Order::where('no', $request->no)->first();
                return view('orders.show', $order);
            } catch (HttpException $e) {
                return abort($e->getStatusCode(), $e->getMessage());
            }
        }
            return abort(401, '非法请求');
    }

异步回调

订阅过程中的回调事件共有四种, 分别是Billing plan createdBilling plan updatedBilling subscription createdBilling subscription updatedPayment sale completed, 而我们更新本地订单的业务只需要用到最后一个(Payment sale completed)即可, 其他的视具体业务而定, 所以我们在创建WebHookEvent的时候需要跟其他回调业务区分开来.

app/Http/Controllers/SubscriptionsController.php

<?php

namespace App\Http\Controllers;

.
.
.
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;

class SubscriptionsController extends Controller
{
    .
    .
    .

    /**
     * @Des 订阅的异步回调处理
     * @Author Mars
     * @param Request $request
     * @return string
     */
    public function payPalNotify(Request $request)
    {
        Log::info('PayPal Subscription Notification', ['request' => ['header' => $request->header(), 'body' => $request->getContent()]]);

        $response = app('paypal')->verify($request, config('paypal.sanbox.subscription_notify_web_hook_id'));

        if (!$response) {
            return 'fail';
        }

        $requestBody = json_decode($response->request_body, true);

        $eventType = Arr::get($requestBody, 'event_type');
        $resourceState = Arr::get($requestBody, 'resource.state');

        if ($eventType == 'PAYMENT.SALE.COMPLETED' && strcasecmp($resourceState, 'completed') == 0) {
            $billingAgreementId = Arr::get($requestBody, 'resource.billing_agreement_id');
            $billingAgreement = app('paypal')->getBillingAgreement($billingAgreementId);
            if (!$billingAgreement) {
                return 'fail';
            }

            // 获取买家信息
            $payerInfo = $billingAgreement->getPayer()->getPayerInfo();
            // 买家地址
            $shippingAddress = $billingAgreement->getShippingAddress();

            // 收录买家信息到用户表
            $email = $payerInfo->getEmail();
            $user = User::where('email', $email)->first();
            if (!$user) {
                $user = User::create([
                    'email' => $email,
                    'name' => $payerInfo->getLastName() . ' ' . $payerInfo->getFirstName(),
                    'password' => bcrypt($payerInfo->getPayerId())
                ]);
            }

            // 获取订单号(因为我在创建计划的时候把本地订单号追加到了description属性里, 大家可以视情况而定)
            $description = $billingAgreement->getDescription();
            $tmp = explode(' - ', $description);
            $orderNo = array_pop($tmp);
            $order = Order::where('no', $orderNo)->first();

            if (!$order) {
                return 'fail';
            }

            // 订阅续费订单(如果查到的本地订单已经付过了且包中的'完成周期数`不是0, 则说明是续费订单, 本地可以新建一个订单标记是续费的. 这部分仅供参考, 具体视大家的业务而定)
            if ($order->paid_at && $billingAgreement->getAgreementDetails()->getCyclesCompleted() != 0) {
                // 产品
                $sku = $order->product;

                // 新建一个本地订单
                $order = new Order([
                    'address' => $shippingAddress->toArray(),
                    'paid_at' => Carbon::now(),
                    'payment_method' => 'paypal-subscription',
                    'payment_no' => $billingAgreementId,
                    'total_amount' => $billingAgreement->getAgreementDetails()
                        ->getLastPaymentAmount()
                        ->getValue(),
                    'remark' => '订阅续费订单 - ' . $billingAgreement->getAgreementDetails()->getCyclesCompleted() . '期',
                ]);
                // 订单关联到当前用户
                $order->user()->associate($user);
                $order->save();
            } else {
                // 首次付款
                $order->update([
                    'paid_at' => Carbon::now(),
                    'payment_method' => 'paypal-subscription',
                    'payment_no' => $billingAgreementId,
                    'user_id' => $user->id,
                    'address' => $shippingAddress->toArray(),
                ]);

                // TODO: 增加销量、发送邮件等业务
                .
                .
                .
            }
            return 'success';
        }
        return 'fail';
    }
}

创建路由

上面的方法中一共需要三个路由, 分别是'创建计划'、'执行订阅'、'订阅付款异步回调'

routes\web.php

<?php
.
.
.
// PayPal-Subscription-CreatePlan
Route::get('subscriptions/{order}/paypal/plan', 'SubscriptionsController@createPlan')
    ->name('subscriptions.paypal.createPlan');
// PayPal-Subscription-Return
Route::get('subscriptions/paypal/return', 'SubscriptionsController@execute')
    ->name('subscriptions.paypal.return');
// PayPal-Subscription-Notify
Route::post('subscriptions/paypal/notify', 'SubscriptionsController@payPalNotify')
    ->name('subscriptions.paypal.notify');

同样的, 不要忘记把异步回调路由加入到白名单中

app/Http/MiddlewareVerifyCsrfToken.php

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
   .
   .
   .

    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array
     */
    protected $except = [
        .
        .
        .
        // PayPal-Subscription-Notify
        'subscriptions/paypal/notify',
    ];
}

设置PayPal-WebHookEvent

同上面提到的设置方法, 我们这里只将Payment sale completed事件勾选即可, 具体过程这里不再赘述.

测试Subscription

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享

复制链接到浏览器打开, 登陆后如下

PayPal支付 - Checkout 收银台 和 Subscription 订阅计划 对接全过程分享
订阅完成.

本地测试异步回调

同上面提到的, 这里不再赘述.

至此, 两种支付的整个过程就算完结啦. 第一次写博文, 文中如有不恰当的地方欢迎各位指点.

转载请注明出处 博客:PayPal 支付-Checkout 收银台和 Subscription 订阅计划全过程分享

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 4年前 自动加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 26

自己先顶一波, 嘻嘻嘻

4年前 评论

@NiZerin 第一次写, 谢谢鼓励~

4年前 评论

Paypal 感觉只是在美国好用. 信用卡支付需要填写太多东西. 准备换到stripe

4年前 评论

@Corplay 下一篇就准备写Stripe了, 大家一起交流呀. 感觉PayPal费率好高啊, 还是支付宝&微信比较亲民.

4年前 评论

@_mars 说好的 Stripe 呢。。。 :heart_eyes:

3年前 评论

@Yu 感谢提醒!!! 忙完这段儿就弄 :joy: :joy: :joy:

3年前 评论

具体什么原因啊?你们知道吗?

file ERROR: Got Http response code 400 when accessing api.sandbox.paypal.com/v1/payments.... {"name":"MALFORMED_REQUEST","message":"Incoming JSON request does not map to API request","information_link":"https://developer.paypal.com/webapps/developer/docs/api/#MALFORMED_REQUEST","debug_id":"4aa06ffa0723b"}

3年前 评论

@Youris 你这个错误很正常, 因为PayPal的沙箱环境hin垃圾, 返回400太正常了, 多试几次. 还有就是在项目日志目录里有个PayPal.log文件,里面有所有你发起的接口请求以及返回结果

3年前 评论

@_mars 之前的問題已經解決,但是為什麼我這邊設置了回調事件,完全沒有觸法,白名單也加了

notify 接口不知道怎麼用

3年前 评论

@Youris 回调事件就是普通的post请求, 只不过请求方是PayPal, 接收方是你. 如果接收不到回调的话你可以先试试自己能不能访问通

3年前 评论

@Youris @_mars 同问,你最后怎么解决回调事件没有触发问题的?沙盒环境的

3年前 评论

@jin3721jin 有的,按照作者方法做即可,而且Paypal自带能够查询回调到结果

3年前 评论

流程好似缺乏了一个考虑,就是用户跳转到PayPal 账户并确认支付后,如果此时出现意想不到的问题(例如服务器刚好down了,或者用用户断网等),无法跳转回服务器,则同步回调失败,后续应该如何处理?是否要写一个cornjob定时检查订单的付款状态?

3年前 评论

$billingAgreement = app(‘paypal’)->getBillingAgreement($billingAgreementId); 中 getBillingAgreement方法怎么定义的?

3年前 评论
_mars (楼主) 2年前

app('paypal')->getPayment($paymentId); hi,这个方法是本地的业务逻辑查询的吗? $paymentId这个是paypal的订单号吗?

3年前 评论

H5 这个可以直接拉起paypal app 支付吗?

2年前 评论

@jalen 当然可以

2年前 评论
jalen 2年前

我这边是订阅支付,目前是沙盒环境。回调太慢了,10多秒才有通知,不知道线上是不是也会有这样的问题。订阅订单查询发现状态是授权状态,非completed,也不支持捕获订单。不知道大家有没有遇到这样的问题?

1年前 评论
_mars (楼主) 1年前

计划和订阅是一对一,还是一对多。试了下一对多也是可以的... 。请问博主有什么建议

1年前 评论

SDK里没有 use PayPal\Api\VerifyWebhookSignature; 这个类, 很奇怪

5天前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!