/** * Stripe webhook 事件处理器 * * 4 个核心事件: * - `checkout.session.completed` 订阅首次购买 / 续费成功(返回 session URL 时 Stripe 触发) * - `customer.subscription.deleted` 订阅取消(用户在 Portal 取消 / 不续费) * - `customer.subscription.updated` 订阅状态变化(plan 升级 / 降级 / 重新激活) * - `invoice.payment_succeeded` 续费周期扣款成功(每个计费周期触发) * * 设计:handler 返回 `Promise`,幂等(用 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"; import { Logger } from "@/utils"; const log = new Logger("LibStripeStripeEvents"); /** * 4 个事件的主分发器 * @returns true 如果事件已处理;false 如果忽略 */ export async function dispatchStripeEvent( event: Stripe.Event, ): Promise { 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 { 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; log.debug("[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: } // 本轮先不接(后端可能还没好)—— // 业务后端收到 webhook 后会自己更新 user.isVip + voiceMinutesRemaining } // ============================================================ // Handler 2: customer.subscription.deleted // ============================================================ /** * 订阅取消 → user.isVip=false * * 注意:voiceMinutesRemaining 保留(不清零)—— 不然已经发送的语音会消失 * (业务决定) */ async function handleSubscriptionDeleted( event: Stripe.CustomerSubscriptionDeletedEvent, ): Promise { const subscription = event.data.object; const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id; log.debug("[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 { const subscription = event.data.object; const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id; log.debug(`[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 { const invoice = event.data.object; const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; // 只关心周期续费(不对首次购买重复补充) if (invoice.billing_reason !== "subscription_cycle") { log.debug( `[stripe-events] invoice.payment_succeeded (skipped, billing_reason=${invoice.billing_reason})`, { eventId: event.id, invoiceId: invoice.id }, ); return; } log.debug("[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: } } // 注:stripe-client 保留给未来扩展使用(例如主动调 `stripe.subscriptions.update()` 升级 plan) // 本轮 handler 没直接调用 getStripeClient()(webhook 是事件驱动,不需要反向查 Stripe) export const _internal_stripeClient = getStripeClient;