192 lines
7.1 KiB
TypeScript
192 lines
7.1 KiB
TypeScript
/**
|
||
* 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";
|
||
import { Logger } from "@/utils";
|
||
|
||
const log = new Logger("LibStripeStripeEvents");
|
||
|
||
/**
|
||
* 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;
|
||
|
||
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: <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;
|
||
|
||
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<void> {
|
||
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<void> {
|
||
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: <plan's voiceMinutesPerDay> }
|
||
}
|
||
|
||
// 注:stripe-client 保留给未来扩展使用(例如主动调 `stripe.subscriptions.update()` 升级 plan)
|
||
// 本轮 handler 没直接调用 getStripeClient()(webhook 是事件驱动,不需要反向查 Stripe)
|
||
export const _internal_stripeClient = getStripeClient;
|