EasyStarter logoEasyStarter

Stripe 支付

配置 Stripe Checkout、Billing Portal 和 Webhook

Stripe 支付集成

EasyStarter 的 Web 端内置了完整的 Stripe 支付支持,开箱即用,包含:

  • 订阅制(月付 / 年付)结账
  • 一次性买断(Lifetime)结账
  • 免费试用期(Trial)
  • Stripe Billing Portal(用户自助管理订阅、取消、更换支付方式、查看发票)
  • 计划升级(月付 → 年付,直接在后台完成,无需跳转 Checkout)
  • Webhook 事件处理(订阅状态同步、发票、退款、争议等)

支付配置分为两部分:

  1. 环境变量:API 密钥与 Webhook 签名密钥,填入服务端 .dev.vars / .env.production
  2. 定价计划:在 packages/app-config/src/app-config.ts 中配置 Stripe Price ID 与价格信息

所需环境变量

STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

注册 Stripe 并获取 API 密钥

官方文档:Stripe API Keys

  1. 前往 stripe.com 注册账号并完成邮箱验证
  2. 登录后进入 Developers → API keys
  3. 复制 Secret key(以 sk_test_ 开头为测试密钥,sk_live_ 为生产密钥)

初始阶段使用测试密钥即可。上线前在同一页面切换到 Live mode 获取正式密钥。

将复制的密钥填入:

apps/server/.dev.vars
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
apps/server/.env.production
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

在 Stripe 创建产品与价格

EasyStarter 默认包含三个计划:FreePro(月付 + 年付)、Lifetime(一次性买断)。

Free 计划无需在 Stripe 创建,仅在本地配置中用作占位。以下为 Pro 和 Lifetime 的创建步骤:

官方文档:Stripe Products & Prices

  1. 进入 Products 页面,点击 Add product
  2. 填写产品名称,例如 Pro
  3. Pricing 区域:
    • 选择 Recurring(订阅),填写价格,选择计费周期(MonthlyYearly
    • 点击 More price options (更多价格选项) 可在同一产品下添加多个价格
  4. 保存后,点击每个价格的详情,复制 Price ID(格式为 price_xxxxxxx
  5. 重复以上步骤为 Lifetime 创建产品,定价类型选择 One time(一次性收费)

每个价格对应一个唯一的 Price ID,下一步将用到。

配置定价计划

将上一步获取的 Price ID 填入 packages/app-config/src/app-config.tsweb.payments.plans 字段:

packages/app-config/src/app-config.ts
web: {
  payments: {
    provider: "stripe",
    plans: [
      {
        id: "free",   // 免费计划,无需 Price ID
      },
      {
        id: "pro",
        prices: [
          {
            id: "monthly",
            provider: "stripe",
            providerPriceId: "price_xxxxxxxxxxxxxxxx",  // Stripe 月付 Price ID
            currency: "usd",
            amountCents: 1000,    // $10.00
            priceType: "subscription",
            interval: "month",
            trialDays: 7,         // 免费试用天数,不需要则删除此字段
            status: "active",
          },
          {
            id: "yearly",
            provider: "stripe",
            providerPriceId: "price_xxxxxxxxxxxxxxxx",  // Stripe 年付 Price ID
            currency: "usd",
            amountCents: 10000,   // $100.00
            priceType: "subscription",
            interval: "year",
            trialDays: 7,
            status: "active",
          },
        ],
      },
      {
        id: "lifetime",
        prices: [
          {
            id: "lifetime",
            provider: "stripe",
            providerPriceId: "price_xxxxxxxxxxxxxxxx",  // Stripe 一次性 Price ID
            currency: "usd",
            amountCents: 20000,   // $200.00
            priceType: "lifetime",
            status: "active",
          },
        ],
      },
    ],
  },
},

字段说明:

字段说明
providerPriceIdStripe 后台的 Price ID,格式 price_xxx
amountCents价格(分),1000 = $10.00
priceType"subscription" 订阅 / "lifetime" 一次性
interval订阅周期:"month" / "year"(lifetime 不填)
trialDays免费试用天数,不需要则删除此字段
status"active" 启用 / "archived" 归档(不显示在定价页)

配置 Stripe Webhook

Webhook 用于接收 Stripe 推送的事件(如支付成功、订阅变更、发票等),是支付状态同步的核心机制。

本地开发(使用 Stripe CLI):

  1. 安装 Stripe CLI

    # macOS
    brew install stripe/stripe-cli/stripe
  2. 登录 Stripe CLI:

    stripe login
  3. 启动本地 Webhook 转发(服务端默认监听 http://localhost:3001):

    stripe listen --forward-to http://localhost:3001/api/webhooks/stripe
  4. 启动后终端会输出一个 Webhook signing secret,格式以 whsec_ 开头,将其填入:

    apps/server/.dev.vars
    STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

生产环境(Stripe Dashboard):

  1. 进入 Developers → Webhooks

  2. 点击 Add endpoint

  3. Endpoint URL 填写你的生产地址:https://your-server.workers.dev/api/webhooks/stripe

  4. Events to send 选择以下事件(EasyStarter 已处理):

    事件说明
    checkout.session.completed结账完成
    checkout.session.async_payment_succeeded异步支付成功
    checkout.session.async_payment_failed异步支付失败
    checkout.session.expired结账会话过期
    customer.subscription.created订阅创建
    customer.subscription.updated订阅变更
    customer.subscription.deleted订阅取消
    payment_intent.succeeded支付意图成功
    payment_intent.payment_failed支付意图失败
    payment_intent.canceled支付意图取消
    invoice.paid发票支付成功
    invoice.payment_failed发票支付失败
    invoice.marked_uncollectible发票标记为无法收取
    invoice.voided发票作废
    charge.dispute.created争议创建
    charge.dispute.updated争议更新
    charge.dispute.closed争议关闭
    charge.refunded退款
  5. 保存后,点击 Webhook 端点详情,复制 Signing secret(格式 whsec_xxx)填入:

    apps/server/.env.production
    STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

填入环境变量并启动

确认两个变量已在开发环境中填写完整:

apps/server/.dev.vars
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

然后启动服务:

pnpm dev:server

同时在另一个终端保持 Stripe CLI 转发运行,即可在本地完整测试支付流程。

测试卡号:Stripe 测试模式下可使用 4242 4242 4242 4242,有效期任意未来日期,CVV 任意三位数字。 更多测试卡见 Stripe Testing Docs

定价路由配置

支付成功 / 取消后的跳转页面在 packages/app-config/src/app-config.tsweb.routes 中配置:

packages/app-config/src/app-config.ts
web: {
  routes: {
    billingSuccess: "/billing/success",  // 支付成功后跳转
    billingCancel: "/billing/cancel",    // 取消支付后跳转
    billingReturn: "/settings/billing",  // Billing Portal 返回后跳转
  },
},

Billing Portal(用户自助管理)

EasyStarter 已内置 Stripe Billing Portal 集成。用户可在 /settings/billing 页面点击管理订阅,系统会自动创建 Portal 会话并跳转至 Stripe 提供的管理界面。

使用前需要在 Stripe 后台启用并配置 Billing Portal:

  1. 进入 Customer Portal Settings
  2. 按需开启取消订阅更换支付方式查看发票等功能
  3. 保存配置后即可生效

生产上线前检查

项目检查点
API 密钥切换为 Live 模式的 sk_live_ 密钥
Webhook Secret生产 Webhook 端点的 whsec_ 签名密钥
Price ID使用 Live 模式下创建的 Price ID
Billing Portal已在 Stripe Dashboard 配置并启用
环境变量推送通过 pnpm run secrets:bulk:production 推送到 Cloudflare Workers

扩展其他支付服务商

EasyStarter 的支付层基于 PaymentProvider 接口设计,内置 Stripe 实现。如需替换为其他服务商(如 PaddleLemonSqueezy 等),只需按以下六步操作,无需改动业务逻辑。

第一步:注册服务商 Key

packages/app-config/src/types.ts 中,将新服务商 key 追加到 SUPPORTED_WEB_PAYMENT_PROVIDERS

packages/app-config/src/types.ts
export const SUPPORTED_WEB_PAYMENT_PROVIDERS = ["stripe", "paddle"] as const;

这会自动同步更新 WebPaymentProviderKeyServerPaymentProviderKey 类型。

第二步:实现 PaymentProvider 接口

apps/server/src/payments/providers/ 下新建目录,实现 PaymentProvider 接口:

apps/server/src/payments/providers/paddle/provider.ts
import type {
  CreateCheckoutInput,
  CreatePortalInput,
  ParsedWebhookEvent,
  PaymentProvider,
  WebhookInput,
} from "../../public/types";

export function createPaddlePaymentProvider(): PaymentProvider {
  return {
    key: "paddle",

    async createCheckoutSession(input: CreateCheckoutInput) {
      // 调用 Paddle SDK 创建结账会话
      // 返回 { providerSessionId, url, expiresAt }
    },

    async createPortalSession(input: CreatePortalInput) {
      // 返回 Paddle 订阅管理页面的 URL
      // 返回 { providerSessionId, url }
    },

    async parseWebhookEvent(input: WebhookInput): Promise<ParsedWebhookEvent> {
      // 验证签名,解析 payload
      // 返回 { providerEventId, type, createdAt, payload }
    },

    async setSubscriptionCancelAtPeriodEnd(input) {
      // 调用 Paddle API 设置到期取消
    },

    async updateSubscriptionPrice(input) {
      // 调用 Paddle API 升级订阅价格
    },
  };
}

接口方法说明:

方法说明
createCheckoutSession创建结账会话,返回跳转 URL
createPortalSession创建订阅管理门户会话,返回跳转 URL
parseWebhookEvent验证 Webhook 签名并解析事件
setSubscriptionCancelAtPeriodEnd设置订阅到期时取消
updateSubscriptionPrice变更订阅价格(用于计划升级)

第三步:实现 Webhook 事件处理器

apps/server/src/payments/providers/paddle/webhook/ 下创建事件处理逻辑,将 Paddle 事件映射到数据库操作:

apps/server/src/payments/providers/paddle/webhook/handle-event.ts
import type { Database } from "@/db";

export async function handlePaddleEvent(db: Database, payload: unknown) {
  const event = payload as { event_type: string; data: unknown };

  switch (event.event_type) {
    case "subscription.created":
    case "subscription.updated":
    case "subscription.canceled": {
      // 同步订阅状态到 billing_subscription 表
      break;
    }
    case "transaction.completed": {
      // 处理一次性购买,写入 billing_purchase 表
      break;
    }
    // 按需处理其他事件...
    default:
      break;
  }
}

参考 apps/server/src/payments/providers/stripe/webhook/ 目录的结构,将复杂事件拆分到独立文件中。

第四步:注册到 Provider 工厂

apps/server/src/payments/providers/index.ts 中,将新 Provider 注册进工厂:

apps/server/src/payments/providers/index.ts
import { createPaddlePaymentProvider } from "./paddle/provider";

const providers: Record<ServerPaymentProviderKey, () => PaymentProvider> = {
  stripe: createStripePaymentProvider,
  paddle: createPaddlePaymentProvider,  // 新增
};

第五步:添加 Webhook 路由

apps/server/src/index.ts 中,为新服务商添加一个专属 Webhook 路由:

apps/server/src/index.ts
app.post("/api/webhooks/paddle", async (c) => {
  const context = await createContext({ context: c });
  const rawBody = await c.req.text();
  const signature = c.req.header("paddle-signature");

  await context.payments.handleWebhookEvent({
    provider: "paddle",
    rawBody,
    signature,
  });

  return c.json({ received: true });
});

服务端的 handleWebhookEvent 会自动将事件路由到对应的 handlePaddleEvent 处理器。

第六步:配置定价计划并切换 Provider

packages/app-config/src/app-config.ts 中,将 web.payments.provider 改为新服务商,并填入对应的 Price ID:

packages/app-config/src/app-config.ts
web: {
  payments: {
    provider: "paddle",   // 切换到新服务商
    plans: [
      {
        id: "pro",
        prices: [
          {
            id: "monthly",
            provider: "paddle",
            providerPriceId: "pri_xxxxxxxxxxxxxxxx",  // Paddle Price ID
            currency: "usd",
            amountCents: 1000,
            priceType: "subscription",
            interval: "month",
            status: "active",
          },
        ],
      },
    ],
  },
},

完成后,所有结账、升级、门户入口都会自动走新服务商,无需改动任何业务代码。