PayPal 支付-Checkout 收銀台和 Subscription 訂閱計划全過程分享


廢話不多說,我們先從請求的生命周期來分析,逐步實現整個過程.

一。生命周期
1. Checkout - 收銀台支付
拆解流程如圖所示 (過程類似支付寶的收銀台):


流程詳解:

  1. 本地應用組裝好參數並請求 Checkout 接口,接口同步返回一個支付 URL;
  2. 本地應用重定向至這個 URL, 登陸 PayPal 賬戶並確認支付,用戶支付后跳轉至設置好的本地應用地址;
  3. 本地請求 PayPal 執行付款接口發起扣款;
  4. PayPal 發送異步通知至本地應用,本地拿到數據包后進行驗簽操作;
  5. 驗簽成功則進行支付完成后的業務 (修改本地訂單狀態、增加銷量、發送郵件等).

2. 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 開發者中心進行配置

以沙箱環境為例,生產一樣


沒有賬號的新建一個,如果有就點進去,拉至最下面,點擊 Add Webhook 創建一個事件,輸入回調地址 https://yoursite.com/payment/paypal/notify, 把 Payments payment created 和 Payment sale completed 勾選,然后確認即可.

PayPal 的回調地址只支持 HTTPS 協議,可以參考下 Nginx 官方給的配置 HTTPS 方法 , 耐心照着步驟一步一步來很好配,這里不做贅述.

PayPal 提供的事件類型有很多,PayPal-Checkout 只用到了 Payments payment created 和 Payment sale completed.

 

配置完記得將 Webhook ID 添加到我們項目的配置中!

測試 Checkout 支付


復制鏈接瀏覽器訪問

登陸后進行支付. (這里不得不吐槽,沙箱環境真的真的真的很慢很慢很慢...)

在開發者中心的沙箱環境中可以一鍵創建測試賬號 (支付用個人賬號), 這里就不做演示了.

從線上的日志中拿到數據包進行本地測試

請求頭:

在控制器中先打印驗簽結果 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);
            .
        .
        .
    }

}


打印結果如下,接下來就可以編寫支付成功后的業務代碼了.

至此,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 created、Billing plan updated、 Billing subscription created、 Billing subscription updated 和 Payment 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


復制鏈接到瀏覽器打開,登陸后如下

訂閱完成.

本地測試異步回調

訂閱的header和body

{"header":{"Content-Type":"application\/json","Cf-Request-Id":"08b2795df100003b0436840000000001","Cdn-Loop":"cloudflare","Cf-Connecting-Ip":"173.0.82.126","Correlation-Id":"ee6f93394eb95","User-Agent":"PayPal\/AUHD-214.0-55417034","Paypal-Auth-Algo":"SHA256withRSA","Paypal-Cert-Url":"https:\/\/api.sandbox.paypal.com\/v1\/notifications\/certs\/CERT-360caa42-fca2a594-1d93a270","Paypal-Auth-Version":"v2","Paypal-Transmission-Sig":"G\/3Wixb0SvkQhe116lGpgGxK2OiPoc7McEopYjPYw\/pcPRQTErscZSMHzkDB9GU4kmMtMwBKFM6iT4uI9U4TJ2EBEsBgoeg8dAY1cka\/YQS76olqE2iYe4nXGp4la+Vo\/jLYJXTanIbEtLqGlXmmPWBkK7a4+2wpI\/8Aeg91PIW\/2ZbTdpVW3DSx64868DPfWPI9aitCSJp1OHxLL2a+6M\/kC4be1IVT3+tYOKXXNZ9WqXNdc3ArTuMff+KpSVO38\/atAvw3mQAiivezNBXMpzL+vTbjesNMEngn1m+6z\/y3GwUAYf4NLz5OKNpab7Ysr9B2yvNWl5HXraLB5b2j5Q==","Paypal-Transmission-Time":"2021-03-08T08:07:03Z","Paypal-Transmission-Id":"446f1120-7fe5-11eb-947c-f9005c1a6ad2","Accept":"*\/*","Cf-Visitor":"{\"scheme\":\"https\"}","X-Forwarded-Proto":"https","Content-Length":"1229","Cf-Ray":"62ca91a98f6f3b04-SJC","X-Forwarded-For":"173.0.82.126","Cf-Ipcountry":"US","Accept-Encoding":"gzip","Connection":"Keep-Alive","Host":"api.geekpandashare.com"},"body":"{\"id\":\"WH-77A20588F8391304V-0YJ27391M4465813T\",\"event_version\":\"1.0\",\"create_time\":\"2021-03-08T08:06:33.726Z\",\"resource_type\":\"sale\",\"event_type\":\"PAYMENT.SALE.COMPLETED\",\"summary\":\"Payment completed for $ 39.99 USD\",\"resource\":{\"billing_agreement_id\":\"I-N54WLLUG3DW6\",\"amount\":{\"total\":\"39.99\",\"currency\":\"USD\",\"details\":{\"subtotal\":\"39.99\"}},\"payment_mode\":\"INSTANT_TRANSFER\",\"update_time\":\"2021-03-08T08:06:13Z\",\"create_time\":\"2021-03-08T08:06:13Z\",\"protection_eligibility_type\":\"ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE\",\"transaction_fee\":{\"currency\":\"USD\",\"value\":\"1.46\"},\"protection_eligibility\":\"ELIGIBLE\",\"links\":[{\"method\":\"GET\",\"rel\":\"self\",\"href\":\"https:\/\/api.sandbox.paypal.com\/v1\/payments\/sale\/6Y505934MD224870E\"},{\"method\":\"POST\",\"rel\":\"refund\",\"href\":\"https:\/\/api.sandbox.paypal.com\/v1\/payments\/sale\/6Y505934MD224870E\/refund\"}],\"id\":\"6Y505934MD224870E\",\"state\":\"completed\",\"invoice_number\":\"\"},\"links\":[{\"href\":\"https:\/\/api.sandbox.paypal.com\/v1\/notifications\/webhooks-events\/WH-77A20588F8391304V-0YJ27391M4465813T\",\"rel\":\"self\",\"method\":\"GET\"},{\"href\":\"https:\/\/api.sandbox.paypal.com\/v1\/notifications\/webhooks-events\/WH-77A20588F8391304V-0YJ27391M4465813T\/resend\",\"rel\":\"resend\",\"method\":\"POST\"}]}"}

異步回調相應數據:

{"auth_algo": "SHA256withRSA","transmission_id": "446f1120-7fe5-11eb-947c-f9005c1a6ad2","cert_url": "https://api.sandbox.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-1d93a270","webhook_id": "82W63144WX618073T","transmission_sig": "G/3Wixb0SvkQhe116lGpgGxK2OiPoc7McEopYjPYw/pcPRQTErscZSMHzkDB9GU4kmMtMwBKFM6iT4uI9U4TJ2EBEsBgoeg8dAY1cka/YQS76olqE2iYe4nXGp4la+Vo/jLYJXTanIbEtLqGlXmmPWBkK7a4+2wpI/8Aeg91PIW/2ZbTdpVW3DSx64868DPfWPI9aitCSJp1OHxLL2a+6M/kC4be1IVT3+tYOKXXNZ9WqXNdc3ArTuMff+KpSVO38/atAvw3mQAiivezNBXMpzL+vTbjesNMEngn1m+6z/y3GwUAYf4NLz5OKNpab7Ysr9B2yvNWl5HXraLB5b2j5Q==","transmission_time": "2021-03-08T08:07:03Z","webhook_event": {"id":"WH-77A20588F8391304V-0YJ27391M4465813T","event_version":"1.0","create_time":"2021-03-08T08:06:33.726Z","resource_type":"sale","event_type":"PAYMENT.SALE.COMPLETED","summary":"Payment completed for $ 39.99 USD","resource":{"billing_agreement_id":"I-N54WLLUG3DW6","amount":{"total":"39.99","currency":"USD","details":{"subtotal":"39.99"}},"payment_mode":"INSTANT_TRANSFER","update_time":"2021-03-08T08:06:13Z","create_time":"2021-03-08T08:06:13Z","protection_eligibility_type":"ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE","transaction_fee":{"currency":"USD","value":"1.46"},"protection_eligibility":"ELIGIBLE","links":[{"method":"GET","rel":"self","href":"https://api.sandbox.paypal.com/v1/payments/sale/6Y505934MD224870E"},{"method":"POST","rel":"refund","href":"https://api.sandbox.paypal.com/v1/payments/sale/6Y505934MD224870E/refund"}],"id":"6Y505934MD224870E","state":"completed","invoice_number":""},"links":[{"href":"https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-77A20588F8391304V-0YJ27391M4465813T","rel":"self","method":"GET"},{"href":"https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-77A20588F8391304V-0YJ27391M4465813T/resend","rel":"resend","method":"POST"}]}}


同上面提到的,這里不再贅述.

至此,兩種支付的整個過程就算完結啦。

異步支付成功回調

{
    "auth_algo":"SHA256withRSA",
    "transmission_id":"a3302ab0-80de-11eb-aacd-47b3747d966f",
    "cert_url":"https://api.sandbox.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-1d93a270",
    "webhook_id":"82W63144WX618073T",
    "transmission_sig":"gDxFglF2VBz0OycfdbyeGcBInu0vgiroBAVBOD5uD4SymiX4cs++9g4jXWMLiRxbnOxLnRSY/al/HVpZyJhzjco/ot0xj6RuPs8DbVwUBfOjiDPlMqpon8p2YX+nHbwXVNy3t0gsZ8boOmcQRwBBVEbDhW8Qtuyjv0c+M7xub03V4sKJXWHJYTduuNHkcfktVRvxopDFbEyFym0eQea6erPA2FuILCkfEPMkiSzOs54K2yS7Ao32u6ybiy1rbdXwnvMlc2P4C95slldyQgZYO1uV4yrnawfxNT33pzB5LFNSASGHYwXlu1T1xTMq/jpenhZYSiL3HDcIEyY4NJkzYw==",
    "transmission_time":"2021-03-09T13:52:07Z",
    "webhook_event":{
        "id":"WH-9GC90780WS280374S-0J366698HK4127702",
        "event_version":"1.0",
        "create_time":"2021-03-09T13:52:04.050Z",
        "resource_type":"sale",
        "event_type":"PAYMENT.SALE.COMPLETED",
        "summary":"Payment completed for $ 39.99 USD",
        "resource":{
            "billing_agreement_id":"I-NP5WJSMVV814",
            "amount":{
                "total":"39.99",
                "currency":"USD",
                "details":{
                    "subtotal":"39.99"
                }
            },
            "payment_mode":"INSTANT_TRANSFER",
            "update_time":"2021-03-09T13:51:27Z",
            "create_time":"2021-03-09T13:51:27Z",
            "protection_eligibility_type":"ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE",
            "transaction_fee":{
                "currency":"USD",
                "value":"1.46"
            },
            "protection_eligibility":"ELIGIBLE",
            "links":[
                {
                    "method":"GET",
                    "rel":"self",
                    "href":"https://api.sandbox.paypal.com/v1/payments/sale/61C034852Y123674K"
                },
                {
                    "method":"POST",
                    "rel":"refund",
                    "href":"https://api.sandbox.paypal.com/v1/payments/sale/61C034852Y123674K/refund"
                }
            ],
            "id":"61C034852Y123674K",
            "state":"completed",
            "invoice_number":""
        },
        "links":[
            {
                "href":"https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-9GC90780WS280374S-0J366698HK4127702",
                "rel":"self",
                "method":"GET"
            },
            {
                "href":"https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-9GC90780WS280374S-0J366698HK4127702/resend",
                "rel":"resend",
                "method":"POST"
            }
        ]
    }
}

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM