支付
TanStack Start 模板的提供商无关结账与 Webhook —— Creem 或 Stripe,持久化于 D1。
支付是一套以 D1 为后端、可在其上构建的端到端计费流程。模板在同一套接口背后 内置了两个可互换的提供商 —— Creem 与 Stripe,通过单个环境变量切换。结账、订单与计费 页面、订阅状态,以及提供商 webhook(含订阅生命周期与退款)都基于真实数据运行。
选择一个提供商
架构
一个小型注册表解析当前提供商;服务端路由与定价 UI 从不直接导入某个提供商。
| 文件 | 作用 |
|---|---|
src/config/payment-config.ts | 提供商选择(getPaymentProvider)与 env 驱动的产品 ID(getPaymentProducts) |
src/lib/payment/index.ts | 提供商注册表 + getProvider()(实例记忆化) |
src/lib/payment/types.ts | PaymentProvider 接口与共享的参数/结果类型 |
src/lib/payment/providers/creem.ts | Creem 实现 |
src/lib/payment/providers/stripe.ts | Stripe 实现 |
src/lib/payment/order-no.ts | 订单号生成(buildOrderNo) |
src/routes/api/payment/create.ts | 已认证的结账创建 |
src/routes/api/payment/notify/$provider.ts | 所有提供商共用的单一 webhook 接收端 |
src/routes/api/payment/portal.ts | 自助计费门户(取决于提供商) |
src/routes/api/payment/query.ts | 按 orderId 查询订单状态 |
src/lib/db/schema/payment.ts | payment 与 subscription 表(D1) |
src/routes/{-$locale}/_marketing/pay-success.tsx | 结账返回页 |
每个提供商都实现同一个接口:
interface PaymentProvider {
readonly type: PaymentProviderType;
createCheckout(params: CreateCheckoutParams): Promise<CreateCheckoutResult>;
handleWebhook(request: WebhookRequest): Promise<void>;
createPortal?(customerId: string, returnUrl: string): Promise<string>;
}切换提供商
设置提供商,并提供该提供商的产品/价格 ID:
# creem(默认)或 stripe
VITE_PAYMENT_PROVIDER=stripe当提供商为 stripe 时,getPaymentProducts() 读取 VITE_STRIPE_PRICE_* ID,
否则读取 VITE_CREEM_PRODUCT_* ID。
VITE_* 取值由 Vite 在构建时内联(公开)。服务端密钥(STRIPE_API_KEY、
CREEM_API_KEY、各 webhook 密钥)从 Worker 环境读取 —— 用 wrangler secret put
设置。
展示价格位于 src/config/price-config.ts,与提供商实际收费相互独立。见
环境变量。
共享流程
定价 CTA → 创建结账
POST /api/payment/create 要求已认证会话,用 Zod 校验 { productId },解析产品,
生成订单号,插入一条 payment 行(status: paying、provider: <当前>),随后调用
当前提供商的 createCheckout。返回 { orderNo, checkoutUrl }。
跳转到托管结账
浏览器被导向 checkoutUrl。成功 URL 为 {origin}/pay-success?orderId={orderNo};
取消则返回 {origin}/#pricing。
成功页 → 查询订单
/pay-success 可调用 GET /api/payment/query?orderId=...,从 payment 表读取
订单状态。
Webhook → 更新数据表
提供商调用 POST /api/payment/notify/{provider}。路由按路径段查找提供商并调用其
handleWebhook,后者校验签名(失败返回 400 而非 500),并更新
payment / subscription 表。
数据模型
两个提供商写入相同的 D1 数据表。subscription 行是用户唯一的权益来源;provider
记录其归属,以便 /api/payment/portal 将计费门户路由到正确的 SDK。
| 列 | 说明 |
|---|---|
payment.provider / subscription.provider | creem | stripe |
payment.thirdOrderNo | 提供商结账/会话 ID |
subscription.customerId | 提供商客户 ID —— 计费门户必需 |
subscription.subscriptionId | 提供商订阅 ID(一次性/终身为 null) |
subscription.productType | month | year | one_time |
新增一个提供商
- 在
payment-config.ts的paymentProviders中添加其值。 - 在
src/lib/payment/providers/中实现PaymentProvider接口。 - 在
src/lib/payment/index.ts的注册表中注册该类。
$provider webhook 路由与 getProvider() 会自动识别它。
已接入的部分
- 订单页(
account/order)列出用户真实的payment历史。 - 订阅页(
account/subscription)展示来自subscription表的实时套餐、状态、 周期与取消情况。 - 计费页(
setting/billing)渲染套餐概要与计费历史,并将 Stripe 客户链接到 Billing Portal 以管理支付方式、发票与取消。 GET /api/payment/query与/api/payment/orders均已认证并限定到当前登录用户。
