Files
cozsweet-frontend-nextjs/src/lib/stripe/stripe-events.ts
T

192 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;