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,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;
|
||||
Reference in New Issue
Block a user