Menu

PayPal 集成

PayPal 在欧美、拉美地区有大量的存量用户,很多人习惯用 PayPal 余额或绑定的账户付款,而不愿意在陌生网站上输入信用卡号。把 PayPal 作为 Stripe 之外的补充支付渠道,可以接住这部分订单。

NEXTY.DEV 的 PayPal 集成支持两种售卖形态:

  • 一次性积分包:定价页直接渲染 PayPal 按钮,在当前页面完成付款,不跳转。
  • 周期订阅:点击后跳转到 PayPal 的授权页面,授权成功后回到网站。

本章介绍 PayPal 平台侧的配置步骤,以及如何把拿到的凭证填进模板。

提示:

  • PayPal 是可选集成。如果你只用 Stripe 收款,可以跳过本章。
  • PayPal 和 Stripe 可以同时启用,每个定价计划由 provider 字段决定走哪个渠道。

注册与准备

  1. 注册一个 PayPal Business 账号(个人账号无法创建订阅产品,也无法收取商业款项)。

  2. 用这个账号登录 PayPal Developer Dashboard。开发者后台和商家后台是两套界面,本章绝大部分操作在开发者后台完成。

注意:

开发者后台左上角有 Sandbox / Live 切换。开发阶段全程待在 Sandbox,等支付流程调通后再把同样的步骤在 Live 模式重做一遍。Sandbox 和 Live 的凭证、Webhook、订阅计划完全独立,不能复用

创建 App 并获取凭证

  1. 进入 Apps & Credentials 页面,确认处于 Sandbox 模式,点击 Create App,类型选择 Merchant
paypal-create-app
  1. 创建完成后进入 App 详情页,可以看到 Client IDSecret key(Secret 需要点击 Show 才会显示)。
paypal-app-credentials
  1. 把它们填进环境变量:
# PayPal 总开关,由全局的 PayPalProvider 读取
NEXT_PUBLIC_ENABLE_PAYPAL=true
# 开发阶段填 sandbox,上线后改为 live
NEXT_PUBLIC_PAYPAL_ENVIRONMENT=sandbox
NEXT_PUBLIC_PAYPAL_CLIENT_ID=你的_Client_ID
PAYPAL_CLIENT_SECRET=你的_Secret_key

值得注意的是:

NEXT_PUBLIC_PAYPAL_ENVIRONMENT 决定了模板请求哪个 API 域名(api-m.sandbox.paypal.comapi-m.paypal.com),也决定了模板从定价配置里读 test 还是 live 的计划 ID。这个值和你填的 Client ID 必须来自同一套环境,否则会报鉴权失败。

创建 Sandbox 测试账号

Sandbox 模式下不能用你自己的真实 PayPal 账号付款,需要 PayPal 生成的测试账号。

进入 Testing Tools - Sandbox Accounts 页面,PayPal 默认已经给你生成了一个 Business 账号(收款方)和一个 Personal 账号(付款方)。点击 Personal 账号的 ⋮ - View/Edit account,可以看到登录邮箱,密码可以在这里重置为你记得住的值。

paypal-sandbox-accounts

后面测试支付时,就用这个 Personal 账号登录 PayPal 付款页面。

创建 Webhook

支付结果、退款、订阅续费全部依赖 Webhook 落库,这一步不能省。

  1. 回到 Apps & Credentials,点击刚才创建的 App,下拉到 Webhooks 区域,点击 Add Webhook
paypal-webhook-create
  1. 填写 Webhook URL:
  • 本地开发填 内网穿透地址 + api 路径(即 <your_forwarded_address>/api/paypal/webhook
  • 生产环境填 生产环境地址 + api 路径(即 <your_server_address>/api/paypal/webhook

内网穿透地址的获取方式和 Stripe 那一章完全一样:在 Cursor 或 VSCode 中设置 Forward Port,端口填本地服务启动端口,切换成 Public,复制生成的 Forwarded Address。

forward-port

注意:

PayPal 不接受 http://localhost 作为 Webhook URL,必须是公网可访问的 https 地址。如果你暂时不想折腾内网穿透,可以先跳过本地 Webhook 调试,见下文「本地联调」。

  1. 勾选事件类型。模板实现了以下 15 个事件的处理逻辑,建议全部勾选:
一次性付款(积分包)
- PAYMENT.CAPTURE.COMPLETED
- PAYMENT.CAPTURE.PENDING
- PAYMENT.CAPTURE.DENIED
- PAYMENT.CAPTURE.DECLINED
- PAYMENT.CAPTURE.REFUNDED
- PAYMENT.CAPTURE.REVERSED
 
订阅扣款
- PAYMENT.SALE.COMPLETED
- PAYMENT.SALE.REFUNDED
- PAYMENT.SALE.REVERSED
 
订阅生命周期
- BILLING.SUBSCRIPTION.ACTIVATED
- BILLING.SUBSCRIPTION.UPDATED
- BILLING.SUBSCRIPTION.SUSPENDED
- BILLING.SUBSCRIPTION.PAYMENT.FAILED
- BILLING.SUBSCRIPTION.CANCELLED
- BILLING.SUBSCRIPTION.EXPIRED

未订阅的事件类型(例如 CHECKOUT.ORDER.APPROVED)模板会直接静默 ack,不会报错,所以多勾也没有副作用。

  1. 保存后,在 Webhook 列表里可以看到这个 Webhook 的 ID,复制到环境变量:
PAYPAL_WEBHOOK_ID=你的_Webhook_ID

注意:

PAYPAL_WEBHOOK_ID 是验签的必要参数。PayPal 的验签方式和 Stripe 不同:它不是本地用密钥算 HMAC,而是把请求头里的签名信息连同这个 Webhook ID 一起发回给 PayPal 的 verify-webhook-signature 接口做校验。所以这个值填错或者没填,所有 Webhook 都会被拒绝。

定价计划写在代码里

在配置具体的售卖形态之前,先明确一点:NEXTY.DEV v4 的定价计划是配置即代码,全部写在 config/pricing.ts,不存数据库、也没有后台 CRUD。每个计划用 provider 字段声明由谁收款,PayPal 的计划就写 provider: 'paypal'

一次性积分包和周期订阅在 PayPal 这边的准备工作完全不同

一次性积分包周期订阅
PayPal 平台侧不需要创建任何东西需要创建 Product 和 Plan
定价配置只填 price + currency还要填 paypalPlanId
前端形态定价卡片内嵌 PayPal 按钮,弹窗付款跳转 PayPal 授权页,授权后回跳

注意:

已经卖出去过的计划不要删除、不要改 id,下架请改成 active: false。历史订单和续费账单都靠这个 slug 反查计划,删掉会导致 Webhook 处理抛错,PayPal 会连续重试好几天。

配置一次性付款(积分包)

一次性付款不需要在 PayPal 后台创建产品或价格。用户点击按钮时,模板后端会拿 config/pricing.ts 里的金额通过 Orders API 动态开一笔订单,所以这一步只改代码:

{
  id: 'pack-mini-paypal',          // 稳定的 slug,订单和积分流水永久引用它
  kind: 'credit_pack',             // 积分包,不是订阅
  credits: 300,                    // 付款成功后发放的积分
  provider: 'paypal',
  price: 5.9,                      // 扣款金额,以这里为准
  currency: 'USD',
  active: true,
  copy: { en: { name: 'Mini Pack', description: '...', features: ['...'] } },
}

注意这里没有 paypalPlanId —— 积分包不需要,填了也没用。

配好之后,定价卡片会自动把 CTA 换成 PayPal 官方按钮(条件是 provider: 'paypal'kind: 'credit_pack',并且配置了 NEXT_PUBLIC_PAYPAL_CLIENT_ID)。完整链路是:

用户点击 PayPal 按钮
  → POST /api/paypal/create-order  后端按配置金额创建订单
  → PayPal 弹窗内完成付款(不离开当前页面)
  → POST /api/paypal/capture-order  后端 capture 并立即发放积分
  → 跳转 /payment/success

提示:

  • 金额一律以服务端配置为准,前端传过来的价格不采信,所以不用担心被改价。
  • currency 会用来初始化 PayPal JS SDK,必须是 PayPal 支持的币种,并且要和你商家账号能收的币种匹配。
  • 模板默认关闭了信用卡直付(disableFunding: "card"),只走 PayPal 账户付款。想放开的话改 lib/paypal/script-options.ts
  • 积分包只能在个人工作区购买,团队工作区下单会被拦截并提示切换工作区。
  • 用户如果用 eCheck 之类的方式付款,capture 会返回 PENDING,这时钱没到账,模板只记一条待处理订单、不发积分,等 PAYMENT.CAPTURE.COMPLETED 事件到达再补发。

配置周期订阅:创建产品与计划

订阅和积分包相反,必须先在 PayPal 侧建好计划才能卖。

PayPal 的订阅由两层对象组成:Product(产品)Plan(计划,含价格和周期),对应关系类似 Stripe 的 Product 和 Price。

方式一:在商家后台创建

  1. Sandbox 环境:用上一步的 Sandbox Business 测试账号登录 sandbox.paypal.com;Live 环境:用你的真实商家账号登录 paypal.com

  2. 进入 Pay & Get Paid - Subscriptions,创建产品。

paypal-subscription-product
paypal-subscription-product
paypal-subscription-product
  1. 为产品创建计划,填写金额、币种、计费周期。
paypal-subscription-plan
  1. 创建完成后可以看到计划 ID,格式形如 P-5ML4271244454362WXNWU5NQ
paypal-plan-id

方式二:用 API 创建

部分国家和地区的 PayPal 后台没有 Subscriptions 菜单,这时候只能走 API。

先用 Client ID 和 Secret 换取 access token:

curl -v -X POST "https://api-m.sandbox.paypal.com/v1/oauth2/token" \
  -u "CLIENT_ID:CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials"

创建产品:

curl -v -X POST "https://api-m.sandbox.paypal.com/v1/catalogs/products" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Pro Plan",
    "description": "Nexty Pro subscription",
    "type": "SERVICE",
    "category": "SOFTWARE"
  }'

用返回的 id 创建计划:

curl -v -X POST "https://api-m.sandbox.paypal.com/v1/billing/plans" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "product_id": "PROD-XXXXXXXXXXXX",
    "name": "Pro Monthly",
    "status": "ACTIVE",
    "billing_cycles": [
      {
        "frequency": { "interval_unit": "MONTH", "interval_count": 1 },
        "tenure_type": "REGULAR",
        "sequence": 1,
        "total_cycles": 0,
        "pricing_scheme": {
          "fixed_price": { "value": "29.90", "currency_code": "USD" }
        }
      }
    ],
    "payment_preferences": {
      "auto_bill_outstanding": true,
      "setup_fee_failure_action": "CONTINUE",
      "payment_failure_threshold": 3
    }
  }'

返回里的 idP- 开头)就是你要的计划 ID。

提示:

  • total_cycles: 0 表示无限循环扣款,这是订阅制想要的行为。
  • 计划创建后价格不可随意修改,调价要新建计划。这一点和 Stripe 的 Price 一样,所以定价决策要尽量想清楚再建。
  • 计划状态必须是 ACTIVECREATED 状态的计划无法用于下单。

把计划 ID 填进定价配置

拿到 P- 开头的计划 ID 后,回到 config/pricing.ts,给订阅计划填上 paypalPlanId

{
  id: 'pro-monthly-paypal',        // 稳定的 slug,订单和积分流水永久引用它
  kind: 'subscription',
  interval: 'month',
  monthlyCredits: 2000,            // 每次扣款成功后重置到这个额度
  provider: 'paypal',
  paypalPlanId: {
    test: 'P-REPLACE_paypal_sandbox',  // Sandbox 计划 ID
    live: 'P-REPLACE_paypal_live',     // Live 计划 ID
  },
  price: 29.9,                     // 展示价格,必须和 PayPal 计划里的金额一致
  currency: 'USD',
  active: true,
  copy: { en: { name: 'Pro', description: '...', features: ['...'] } },
}

注意:

  • 和积分包相反,订阅计划的 price 只用于卡片展示,真正扣款的金额以 PayPal 计划为准。两边写得不一致,用户看到的和扣到的就是两个数,务必核对。
  • test / liveNEXT_PUBLIC_PAYPAL_ENVIRONMENT 决定读哪个,两边都要填。

更多定价配置的说明见支付系统文档

本地联调

两条链路分开验,都用 Sandbox 的 Personal 测试账号付款。

一次性付款

  1. 打开定价页,确认积分包卡片的 CTA 变成了 PayPal 官方按钮。按钮没出来,先查 NEXT_PUBLIC_PAYPAL_CLIENT_ID 有没有配、计划的 providerkind 对不对。
  2. 点击按钮,在弹窗里用测试账号付款。付完不会离开定价页,而是由前端调用 capture 接口后跳到 /payment/success
  3. 检查数据库的订单记录和用户积分余额,确认金额和积分数与配置一致。

周期订阅

  1. 点击订阅计划的按钮,确认跳到了 PayPal 的授权页面(地址形如 sandbox.paypal.com/webapps/billing/subscriptions?ba_token=...)。跳不过去一般是 paypalPlanId 填错或计划不是 ACTIVE 状态。
  2. 授权后回跳到成功页,检查订阅记录的状态和当期积分。
  3. 续费不用等真实周期,用下面的脚本模拟。

Webhook

模板在 NODE_ENV=development会跳过签名验证(PayPal 无法给 localhost 投递可验签的真实事件),因此本地可以直接把事件 POST 到 /api/paypal/webhook 来测试。模板提供了一个订阅续费的模拟脚本:

# 先改脚本里 CONFIG 段的 subscriptionId / userId / planId 等字段
node scripts/test-paypal-renewal.mjs
 
# 也可以用命令行参数覆盖
node scripts/test-paypal-renewal.mjs --saleId=SALE-123 --amount=9.99

注意:

PayPal 的 Webhook 按事件 ID 幂等,重复用同一个 saleId 触发,服务端会判定为已处理并跳过。测试多次记得换 ID。

切换到 Live 模式

Sandbox 调通后,Live 模式需要把整套步骤重做一遍。可以把下面的清单当做 Checklist:

  1. 开发者后台切到 Live,创建 Live App,拿到新的 Client ID 和 Secret。
  2. 更新环境变量:NEXT_PUBLIC_PAYPAL_ENVIRONMENT=live,换上 Live 的 NEXT_PUBLIC_PAYPAL_CLIENT_IDPAYPAL_CLIENT_SECRET
  3. 在 Live App 下重新创建 Webhook,URL 指向生产域名,事件勾选同上,把新的 Webhook ID 填进 PAYPAL_WEBHOOK_ID
  4. 在真实商家后台重新创建订阅产品和计划,把 P- 开头的 Live 计划 ID 填进 config/pricing.tspaypalPlanId.live一次性积分包不需要这一步,它没有平台侧对象,换完凭证就直接生效。
  5. 确认商家账号已完成邮箱验证和收款资质,否则订单会卡在 Pending。

注意:

Live 的 Webhook URL 必须和你的网站实际访问地址一致(带不带 www 是两个地址),否则收不到事件。

和 Stripe 的几点差异

集成前先知道这些差异,可以少踩坑:

  • 没有托管的账单门户。Stripe 有 Billing Portal,PayPal 没有对应的东西,模板里点击「管理订阅」会跳转到用户自己的 PayPal 自动付款页面 https://www.paypal.com/myaccount/autopay/。取消订阅则是模板调 API 完成的。
  • 一个结算主体同时只能有一个有效订阅。想换计划需要先取消再购买,否则下单会被拦截。
  • 付款可能处于 Pending 状态。用 eCheck 之类的方式付款时,PayPal 会先返回 PENDING,钱还没到账。模板此时只落一条待处理订单、不发积分,等到 PAYMENT.CAPTURE.COMPLETED 事件到达才真正发放。如果一直没等到,定时任务 /api/cron/credits 会做一次兜底核对,记得配置 CRON_SECRET 并挂上定时触发。
  • 积分包不支持团队工作区购买,只能在个人工作区下单,这条规则和 Stripe 一致。

结语

到这里 PayPal 的集成就完成了。建议在 Sandbox 把「一次性付款 - 积分到账」「订阅 - 续费 - 取消」「退款 - 积分扣回」三条链路各跑一遍,再切 Live。