跳转到主要内容
vibestrap 内置一个统一的支付门面(facade),背后接了五个 Provider —— Stripe、 Paddle、Lemon Squeezy、Creem、NOWPayments(crypto)。在 siteConfig.payment.provider 里选一个, 配上对应的环境变量,业务代码继续用同一份 paymentManager API 即可。 所有 Provider 的 webhook 都会被归一化成同一个 NormalizedEvent 结构, 所以发放积分、签发 license、记录分销佣金这类业务代码只用写一次, 跟收钱的是哪家无关。

PaymentManager 的工作方式

门面在 src/payment/index.ts。它在启动时根据 siteConfig.payment.provider 选定当前 Provider:
function pickProvider(): PaymentProvider {
  switch (siteConfig.payment.provider) {
    case 'stripe':       return stripeProvider;
    case 'paddle':       return paddleProvider;
    case 'lemonsqueezy': return lemonSqueezyProvider;
    case 'creem':        return creemProvider;
    case 'nowpayments':  return nowpaymentsProvider;
  }
}
export const paymentManager: PaymentProvider = pickProvider();
每个 Provider 都实现 src/payment/types.ts 里同一份双方法契约: createCheckout(opts)createPortalLink(opts)。调用方(server action、 设置页等)永远只 import paymentManager,根本不知道当前是哪家。

怎么选

Provider最适合费率是否 MoR备注
Stripe北美/欧洲/全球信用卡2.9% + 30¢增值税/销售税自己处理。开发体验最好。
Paddle全球、合规要求高~5% + 50¢替你处理欧盟 VAT 和美国销售税。
Lemon Squeezy个人开发者想要 MoR5% + 50¢注册门槛最低。已被 Stripe 收购。
Creem中国大陆用户浮动是(境内)支持支付宝 + 微信支付。需要中国公司主体。
NOWPaymentscrypto 原生买家0.5%BTC/ETH/USDC 等 300+ 币种,无 KYC。仅支持一次性付款,不支持订阅。
常见路径:第一天用 Lemon Squeezy(不需要实体公司)上线,等有了美国/欧盟的 LLC 切到 Stripe,要做中国市场再加个 Creem。

Webhook 路由

每个 Provider 在 app/api/webhooks/ 下都有独立路由:
ProviderURL
Stripe/api/webhooks/stripe
Paddle/api/webhooks/paddle
Lemon Squeezy/api/webhooks/lemonsqueezy
Creem/api/webhooks/creem
NOWPayments/api/webhooks/nowpayments
只用注册你正在用的那家。其他路由在密钥未配置时会返回 404。

统一事件结构

每个 Provider 的 parseWebhook 都会输出 src/payment/types.ts 里的 NormalizedEvent
{
  type: 'checkout.completed' | 'invoice.paid' | 'subscription.activated' | ...,
  provider: 'stripe' | 'paddle' | 'lemonsqueezy' | 'creem' | 'nowpayments',
  eventId: string,
  payload: NormalizedPayload, // userId、email、sessionId、invoiceId、type、scene 等
  raw: unknown,               // 原始事件,供需要 fallback 的地方使用
}
src/payment/handlers/core.ts 里的 processNormalizedEvent 只读归一化字段。 要新增一个 Provider,只需要实现 createCheckoutcreatePortalLink、一个 normalize*Event 函数即可,业务逻辑零重复。

幂等

Stripe(其他几家也一样)只要响应不是 2xx 就会重投 webhook,所以核心 handler 对 payment.invoiceIdsrc/db/app.schema.ts 里的唯一索引)做幂等,没有 invoice 的回退到 payment.sessionId
if (p.invoiceId) {
  const existing = await db.query.payment.findFirst({
    where: eq(payment.invoiceId, p.invoiceId),
  });
  if (existing) return; // 已处理过 —— 直接 no-op
}
也就是说,同一个事件被收到 5 次也没事 —— 只有第一次会真正写入 payment 行并发放积分。

验证

  1. siteConfig.payment.provider 设成想用的 Provider。
  2. 配好对应的环境变量(见各 Provider 的子文档)。
  3. pnpm dev,打开 /pricing,点结账 —— 应该跳到 Provider 的托管收银台。
  4. 用测试卡完成支付。
  5. Provider 的 webhook 会打到你的路由;查一下 Postgres:
    select id, provider, scene, amount from payment order by created_at desc limit 5;
    
  6. 重发 webhook,应该看到同一行数据,不会有重复。

常见坑

  • 半路换 Provider 不清理在途订单 —— 已发出的 checkout 在新 Provider 下 会 404。先手工 cancel 一遍再切。
  • 忘记按 Provider 配 priceIdEnv —— 当前 Provider 下的 promo 和 standard env 都得能解析出来,哪怕值是空字符串。
  • 本地开发没有公网隧道 —— webhook 到不了 localhost。用 Stripe CLI、 ngrok 或 cloudflared。
  • 测试与正式的 key 混用 —— 每家 Provider 的测试 / 正式 key 是分开的, webhook 签名密钥也是分开的,对不上就过不了校验。
  • 解析报错就直接 return —— Provider 会重投,等你修好 bug 后会重复发 积分。任何路径都要做幂等写入。

官方文档