PayPal 支付:Checkout 收银台与 Subscription 订阅计划流程解析

今天我们将深入探讨 PayPal 支付的完整流程,从请求生命周期开始,逐步实现 Checkout 收银台和 Subscription 订阅功能。

一、支付生命周期

1. Checkout - 收银台支付

流程示意图如下(与支付宝的收银台类似):
Checkout 付款流程

流程详解:

  1. 本地应用组装参数并请求 Checkout 接口,获取支付 URL。
  2. 本地应用重定向至支付 URL,用户在 PayPal 登陆并确认支付,支付后跳转至本地应用地址。
  3. 本地应用请求 PayPal 执行付款接口发起扣款。
  4. PayPal 向本地应用发送异步通知,本地接收数据包并进行验签。
  5. 验签成功后执行支付完成后的操作(如修改订单状态、增加销量、发送邮件等)。

2. Subscription - 订阅支付

流程示意图:
Subscription 付款流程

流程详解:

  1. 创建一个订阅计划。
  2. 激活该计划。
  3. 用激活的计划创建订阅申请。
  4. 本地跳转至订阅申请链接,获取用户授权并完成首期付款,用户支付后跳转至本地应用地址。
  5. 回跳后,发起执行订阅请求。
  6. 接收到订阅授权异步回调结果,验证支付,成功后进行后续操作。

二、具体实现

理解了流程后,我们接下来进行编码实现。GitHub 上有许多 SDK,这里我们使用官方提供的 SDK。

Checkout 实现

1. 安装 PayPal 扩展

bash
$ composer require paypal/rest-api-sdk-php:* // 选择最新版本

2. 创建 PayPal 配置文件

bash
$ touch config/paypal.php

配置内容如下(沙箱和生产环境两套配置):
php
<?php

return [
// PayPal 沙箱配置
'sandbox' => [
'client_id' => env('PAYPAL_SANDBOX_CLIENT_ID', ''),
'secret' => env('PAYPAL_SANDBOX_SECRET', ''),
'notify_web_hook_id' => env('PAYPAL_SANDBOX_NOTIFY_WEB_HOOK_ID', ''),
'checkout_notify_web_hook_id' => env('PAYPAL_SANDBOX_CHECKOUT_NOTIFY_WEB_HOOK_ID', ''),
'subscription_notify_web_hook_id' => env('PAYPAL_SANDBOX_SUBSCRIPTION_NOTIFY_WEB_HOOK_ID', ''),
],

// PayPal 生产配置
'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', ''),
],

];

3. 创建 PayPal 服务类

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

4. 编写 Checkout 方法

可以参考官方提供的 DEMO

php
<?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
{
protected $config;
protected $notifyWebHookId;
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',
        '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;
    }
}

}

5. 注册 PayPal 服务类到容器中

打开文件 app/Providers/AppServiceProvider.php,添加如下代码:
php
<?php

namespace App\Providers;

use App\Services\PayPalService;

class AppServiceProvider extends ServiceProvider
{
public function register()
{
$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);
});
}
}

6. 创建控制器

bash
$ php artisan make:controller PaymentsController

php
<?php

namespace App\Http\Controllers;

use App\Models\Order;

class PaymentsController 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]);
}

}

7. 回调方法

php
// app/Http/Controllers/PaymentController.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: 处理支付失败的情况
    }
}

}

8. 验签方法

在 PayPalService 中添加验签方法:
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()]);
        return false;
    }
}

}

9. 异步回调

在支付控制器内添加异步回调处理方法:
php
// app/Http/Controllers/PaymentController.php
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);
    if ($data['event_type'] == 'PAYMENT.SALE.COMPLETED') {
        // TODO: 这里编写支付完成后的业务
        return 'success';
    }

    return 'fail';
}

}

10. 创建路由

php
// routes/web.php
// PayPal-Checkout
Route::get('payment/{order}/paypal', 'PaymentsController@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 请求,需将其路由添加到 CSRF 白名单中,以便 PayPal 访问。

php
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'payment/paypal/notify',
];

11. 设置 PayPal Webhook Event

访问 PayPal开发者中心 进行配置。

以沙箱环境为例,创建 Webhook 的回调地址为 https://yoursite.com/payment/paypal/notify,并勾选 Payments payment createdPayment sale completed 事件。

PayPal 的回调地址只支持 HTTPS 协议。

12. 测试 Checkout 支付

复制链接在浏览器中访问,登录后进行支付(沙箱环境可能会比较慢)。

注意:在开发者中心的沙箱环境中可以一键创建测试账号。

13. 请求头与验签

php
// app/Http/Controllers/PaymentController.php
public function payPalNotify(Request $request)
{
$response = app('paypal')->verify($request, config('paypal.sandbox.checkout_notify_web_hook_id'));
dd($response); // 打印验签结果
}

二、Subscription 订阅支付

下载并查看官方 DEMO 来获取更多详细信息。

创建计划并激活计划:

php
// app/Services/PayPalService.php
public function createPlan(Order $order)
{
try {
// 创建并激活计划的逻辑
// ...
return $result;
} catch (HttpException $e) {
Log::error('PayPal Create Plan Failed', ['msg' => $e->getMessage()]);
return false;
}
}

其他代码类似 Checkout 部分,具体方法如创建订阅申请、执行订阅以及控制器逻辑。此外,确保在路由中添加相应的处理映射。


至此,PayPal 的 Checkout 和 Subscription 支付流已经完成,希望能对您的开发工作有所帮助。如有不当之处请多多指正。

👉 野卡 | 一分钟注册,轻松订阅海外线上服务

THE END