feat: integrate Stripe payment with subscription plans
Add Stripe payment integration across the project: - Add @stripe/stripe-js dependency - Configure Stripe environment variables (secret key, publishable key, webhook secret) for dev, local, and production environments - Add Product/Price IDs for monthly, quarterly, and annual subscription tiers - Extend subscription plan data with voiceMinutesPerDay quotas (30/45/60 minutes per tier) - Update .gitignore to exclude implementation_plan.md
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Stripe Node.js SDK 单例(懒初始化)
|
||||
*
|
||||
* 为什么单例:
|
||||
* - `new Stripe(secretKey)` 会发起一个内部 HTTP client
|
||||
* - 不需要每次 `new`(也不需要)
|
||||
* - 模块级 `let client` —— 首次调用 `getStripeClient()` 时初始化,之后复用
|
||||
*
|
||||
* 调用方:
|
||||
* - 仅在 Next.js API route / Server Component 用(secret key 不要暴露给 client bundle)
|
||||
* - client 端用 `@stripe/stripe-js` 的 `loadStripe(publishableKey)`
|
||||
*/
|
||||
import Stripe from "stripe";
|
||||
|
||||
let _client: Stripe | null = null;
|
||||
|
||||
/**
|
||||
* 获取 Stripe SDK 单例(懒初始化)
|
||||
* - 首次调用会 `new Stripe(secretKey)`,后续直接返回 cache
|
||||
* - 如果 `STRIPE_SECRET_KEY` 未设置,抛错(避免 silent failure)
|
||||
*/
|
||||
export function getStripeClient(): Stripe {
|
||||
if (_client) return _client;
|
||||
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
throw new Error(
|
||||
"STRIPE_SECRET_KEY is not set. Please configure it in .env.local (or your env).",
|
||||
);
|
||||
}
|
||||
|
||||
_client = new Stripe(secretKey, {
|
||||
// Stripe API 版本(跟 SDK 22.2.1 默认版本对应 —— "2026-05-27.dahlia")
|
||||
// 用 `as never` 绕过 SDK 的字符串字面量联合类型限制(SDK 22.x 限定了具体允许的版本,
|
||||
// 但我们暂时只关心 "能用";后续如需 lock 到具体版本,再改成 as const 字符串字面量)
|
||||
apiVersion: "2026-05-27.dahlia" as never,
|
||||
typescript: true,
|
||||
appInfo: {
|
||||
name: "cozsweet-frontend-nextjs",
|
||||
version: "0.1.0",
|
||||
},
|
||||
});
|
||||
return _client;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Stripe webhook 事件处理器
|
||||
*
|
||||
* 4 个核心事件:
|
||||
* - `checkout.session.completed` 订阅首次购买 / 续费成功(返回 session URL 时 Stripe 触发)
|
||||
* - `customer.subscription.deleted` 订阅取消(用户在 Portal 取消 / 不续费)
|
||||
* - `customer.subscription.updated` 订阅状态变化(plan 升级 / 降级 / 重新激活)
|
||||
* - `invoice.payment_succeeded` 续费周期扣款成功(每个计费周期触发)
|
||||
*
|
||||
* 设计:handler 返回 `Promise<void>`,幂等(用 event.id 去重)—— Stripe 保留重试权利
|
||||
*
|
||||
* 注:本轮handler 暂不调后端 API(因为后端 Stripe 接入可能还没完成)——
|
||||
* 只记录 event 到服务端 console,并 TODO 注释后续接入位置。
|
||||
* webhook 验签 + 分发在 `/api/payment/webhook` API route 里处理(见 route.ts)。
|
||||
*/
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeClient } from "./stripe-client";
|
||||
|
||||
/**
|
||||
* 4 个事件的主分发器
|
||||
* @returns true 如果事件已处理;false 如果忽略
|
||||
*/
|
||||
export async function dispatchStripeEvent(
|
||||
event: Stripe.Event,
|
||||
): Promise<boolean> {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await handleCheckoutCompleted(event);
|
||||
return true;
|
||||
case "customer.subscription.deleted":
|
||||
await handleSubscriptionDeleted(event);
|
||||
return true;
|
||||
case "customer.subscription.updated":
|
||||
case "customer.subscription.created":
|
||||
await handleSubscriptionUpdated(event);
|
||||
return true;
|
||||
case "invoice.payment_succeeded":
|
||||
await handleInvoicePaymentSucceeded(event);
|
||||
return true;
|
||||
default:
|
||||
// 其他事件不关心(如 `payment_intent.succeeded`、试用结束警告等)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Handler 1: checkout.session.completed
|
||||
// ============================================================
|
||||
/**
|
||||
* 首次购买 / 续费成功 → 设置 user.isVip=true,补充 voiceMinutesRemaining
|
||||
*
|
||||
* 业务事实:
|
||||
* - mode: "subscription" 的 Checkout Session 完成即代表订阅生效
|
||||
* - 可以从 event.data.object 取到 customer + subscription 字段
|
||||
* - 如果是续费(`subscription` 已存在)→ 同样处理(补充配额)
|
||||
*/
|
||||
async function handleCheckoutCompleted(
|
||||
event: Stripe.CheckoutSessionCompletedEvent,
|
||||
): Promise<void> {
|
||||
const session = event.data.object;
|
||||
const customerId =
|
||||
typeof session.customer === "string" ? session.customer : session.customer?.id;
|
||||
const subscriptionId =
|
||||
typeof session.subscription === "string"
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
console.log("[stripe-events] checkout.session.completed", {
|
||||
eventId: event.id,
|
||||
customerId,
|
||||
subscriptionId,
|
||||
amountTotal: session.amount_total,
|
||||
currency: session.currency,
|
||||
mode: session.mode,
|
||||
});
|
||||
|
||||
// TODO: 后端接入 Stripe 后,这里调后端 API
|
||||
// POST /api/internal/users/:userId/set-vip
|
||||
// { isVip: true, voiceMinutes: <plan's voiceMinutesPerDay> }
|
||||
// 本轮先不接(后端可能还没好)——
|
||||
// 业务后端收到 webhook 后会自己更新 user.isVip + voiceMinutesRemaining
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Handler 2: customer.subscription.deleted
|
||||
// ============================================================
|
||||
/**
|
||||
* 订阅取消 → user.isVip=false
|
||||
*
|
||||
* 注意:voiceMinutesRemaining 保留(不清零)—— 不然已经发送的语音会消失
|
||||
* (业务决定)
|
||||
*/
|
||||
async function handleSubscriptionDeleted(
|
||||
event: Stripe.CustomerSubscriptionDeletedEvent,
|
||||
): Promise<void> {
|
||||
const subscription = event.data.object;
|
||||
const customerId =
|
||||
typeof subscription.customer === "string"
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
console.log("[stripe-events] customer.subscription.deleted", {
|
||||
eventId: event.id,
|
||||
customerId,
|
||||
subscriptionId: subscription.id,
|
||||
canceledAt: subscription.canceled_at,
|
||||
status: subscription.status,
|
||||
});
|
||||
|
||||
// TODO: POST /api/internal/users/:userId/set-vip { isVip: false }
|
||||
// voiceMinutesRemaining 不动(保留余额)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Handler 3: customer.subscription.updated / created
|
||||
// ============================================================
|
||||
/**
|
||||
* 订阅状态变化 → 记录用于后续扩展(plan 升级 / 降级 / 重新激活)
|
||||
*
|
||||
* 本轮只记录,未来如果要 "升级 plan 补充差价" 之类的再加
|
||||
*/
|
||||
async function handleSubscriptionUpdated(
|
||||
event:
|
||||
| Stripe.CustomerSubscriptionUpdatedEvent
|
||||
| Stripe.CustomerSubscriptionCreatedEvent,
|
||||
): Promise<void> {
|
||||
const subscription = event.data.object;
|
||||
const customerId =
|
||||
typeof subscription.customer === "string"
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
console.log(`[stripe-events] customer.subscription.${event.type}`, {
|
||||
eventId: event.id,
|
||||
customerId,
|
||||
subscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
itemsCount: subscription.items.data.length,
|
||||
});
|
||||
|
||||
// TODO: 如果要处理 plan 升级 / 降级 → POST /api/internal/users/:userId/update-subscription
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Handler 4: invoice.payment_succeeded
|
||||
// ============================================================
|
||||
/**
|
||||
* 续费周期扣款成功 → 补充 voiceMinutesRemaining
|
||||
*
|
||||
* 触发:每个计费周期 Stripe 自己扣款成功后(与 `customer.subscription.updated` 不同)
|
||||
* - `billing_reason: "subscription_cycle"`:周期续费(我们要的)
|
||||
* - `billing_reason: "subscription_create"`:首次购买(与 `checkout.session.completed` 重叠)
|
||||
* - `billing_reason: "subscription_update"`:plan 升级 / 降级后的补差价
|
||||
*
|
||||
* 幂等保护:用 `invoice.id` 去重(webhook 会重试,可能同一次扣款发多次)
|
||||
*/
|
||||
async function handleInvoicePaymentSucceeded(
|
||||
event: Stripe.InvoicePaymentSucceededEvent,
|
||||
): Promise<void> {
|
||||
const invoice = event.data.object;
|
||||
const customerId =
|
||||
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
// 只关心周期续费(不对首次购买重复补充)
|
||||
if (invoice.billing_reason !== "subscription_cycle") {
|
||||
console.log(
|
||||
`[stripe-events] invoice.payment_succeeded (skipped, billing_reason=${invoice.billing_reason})`,
|
||||
{ eventId: event.id, invoiceId: invoice.id },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[stripe-events] invoice.payment_succeeded (subscription_cycle)", {
|
||||
eventId: event.id,
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
amountPaid: invoice.amount_paid,
|
||||
});
|
||||
|
||||
|
||||
// TODO: POST /api/internal/users/:userId/top-up-voice-minutes
|
||||
// { minutes: <plan's voiceMinutesPerDay> }
|
||||
}
|
||||
|
||||
// 注:stripe-client 保留给未来扩展使用(例如主动调 `stripe.subscriptions.update()` 升级 plan)
|
||||
// 本轮 handler 没直接调用 getStripeClient()(webhook 是事件驱动,不需要反向查 Stripe)
|
||||
export const _internal_stripeClient = getStripeClient;
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Stripe product / price id 映射
|
||||
*
|
||||
* 为什么不直接存到 `subscription-plans.ts`:
|
||||
* - product/price id 是部署环境配置(不同环境不同 product)
|
||||
* - 套餐本身的 `name` / `price` / `highlight` 是业务常量(不随环境变)
|
||||
* - 分离后,套餐表只包含业务字段,产品 id 走 env —— 避免硬编码 + 生产安全
|
||||
*
|
||||
* env 命名规范:
|
||||
* - STRIPE_PRODUCT_<PLAN_ID_UPPER> → Product ID
|
||||
* - STRIPE_PRICE_<PLAN_ID_UPPER> → Price ID
|
||||
* - 例:STRIPE_PRODUCT_MONTHLY, STRIPE_PRICE_QUARTERLY
|
||||
*/
|
||||
import type { SubscriptionPlanId } from "@/data/constants/subscription-plans";
|
||||
|
||||
/**
|
||||
* 获取 Stripe Product ID(从 env)
|
||||
*
|
||||
* @throws Error 如果 env 未配置(避免 silent failure)
|
||||
*/
|
||||
export function getPlanProductId(planId: SubscriptionPlanId): string {
|
||||
const envKey = `STRIPE_PRODUCT_${planId.toUpperCase()}`;
|
||||
const productId = process.env[envKey];
|
||||
if (!productId || productId.startsWith("prod_replace_")) {
|
||||
throw new Error(
|
||||
`Stripe product id is not configured. Set ${envKey} in .env.local.`,
|
||||
);
|
||||
}
|
||||
return productId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Stripe Price ID(从 env)
|
||||
*
|
||||
* Price ID 是真正用在 `stripe.checkout.sessions.create({ line_items: [{ price: "price_xxx" }] })` 里的
|
||||
*
|
||||
* @throws Error 如果 env 未配置
|
||||
*/
|
||||
export function getPlanPriceId(planId: SubscriptionPlanId): string {
|
||||
const envKey = `STRIPE_PRICE_${planId.toUpperCase()}`;
|
||||
const priceId = process.env[envKey];
|
||||
if (!priceId || priceId.startsWith("price_replace_")) {
|
||||
throw new Error(
|
||||
`Stripe price id is not configured. Set ${envKey} in .env.local.`,
|
||||
);
|
||||
}
|
||||
return priceId;
|
||||
}
|
||||
Reference in New Issue
Block a user