Files
moeka-project/server/apps/api/src/routes/stripe/operations/webhook.ts
T
2026-08-26 20:13:10 +08:00

338 lines
13 KiB
TypeScript

import type Stripe from 'stripe'
import type { RevenueMetrics } from '../../../otel'
import type { BillingService } from '../../../services/domain/billing/billing-service'
import type { FluxService } from '../../../services/domain/flux'
import type { ProductEventService } from '../../../services/domain/product-events'
import type { StripeService } from '../../../services/domain/stripe'
import { useLogger } from '@guiiai/logg'
import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error'
import { errorMessageFromUnknown } from '../../../utils/error-message'
const logger = useLogger('stripe')
interface StripeSubscriptionEventContext {
userId: string
stripeCustomerId: string
stripeSubscriptionId: string
stripePriceId?: string
subscriptionStatus?: string
amountPaid?: number
currency?: string
}
export interface WebhookOperationDeps {
stripe: Stripe | null
webhookSecret: string | undefined
fluxService: FluxService
stripeService: StripeService
billingService: BillingService
metrics?: RevenueMetrics | null
productEventService?: ProductEventService
}
export interface WebhookOperationInput {
signature: string | null
body: string
}
/**
* Processes Stripe webhook events.
*
* Use when:
* - The route has captured the raw request body.
* - Stripe signature verification must happen before event dispatch.
*
* Expects:
* - A configured Stripe client and webhook secret.
*
* Returns:
* - `{ received: true }` after known and unknown events are accepted.
*/
export function createWebhookOperation(deps: WebhookOperationDeps) {
return async (input: WebhookOperationInput): Promise<{ received: true }> => {
if (!deps.stripe || !deps.webhookSecret)
throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')
if (!input.signature)
throw createBadRequestError('No signature', 'MISSING_SIGNATURE')
let event: Stripe.Event
try {
event = deps.stripe.webhooks.constructEvent(input.body, input.signature, deps.webhookSecret)
}
catch (err: unknown) {
throw createBadRequestError(`Webhook Error: ${errorMessageFromUnknown(err)}`, 'WEBHOOK_ERROR')
}
logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')
deps.metrics?.stripeEvents.add(1, { event_type: event.type })
switch (event.type) {
case 'checkout.session.completed': {
const result = await handleCheckoutSessionCompleted(event.id, event.data.object, deps.fluxService, deps.stripeService, deps.billingService)
deps.metrics?.stripeCheckoutCompleted.add(1)
// Revenue capture in smallest currency unit (e.g. cents).
// Cross-currency aggregation is meaningless, so always group by `currency` in queries.
if (event.data.object.amount_total != null && event.data.object.currency) {
deps.metrics?.stripeRevenue.add(event.data.object.amount_total, {
currency: event.data.object.currency,
source: 'checkout',
})
}
// Record the product conversion only after the handler actually
// processed the checkout. Malformed sessions (missing userId,
// invalid fluxAmount) take the early-return path above.
if (result.processed) {
const userId = event.data.object.metadata?.userId
if (userId) {
const fluxAmount = Number(event.data.object.metadata?.fluxAmount)
const posthogDistinctId = event.data.object.metadata?.posthogDistinctId
const posthogSessionId = event.data.object.metadata?.posthogSessionId
void deps.productEventService?.track({
userId,
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
eventId: event.data.object.id,
source: 'stripe.webhook',
metadata: {
amount_total: event.data.object.amount_total,
currency: event.data.object.currency,
flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null,
stripe_checkout_session_id: event.data.object.id,
stripe_customer_id: typeof event.data.object.customer === 'string' ? event.data.object.customer : event.data.object.customer?.id ?? null,
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
...(posthogSessionId && { posthog_session_id: posthogSessionId }),
},
})
}
}
break
}
case 'customer.created':
case 'customer.updated': {
await handleCustomerEvent(event.data.object, deps.stripeService)
break
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await handleSubscriptionEvent(event.data.object, deps.stripeService)
deps.metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') })
break
}
case 'invoice.created':
case 'invoice.updated':
case 'invoice.paid':
case 'invoice.payment_failed': {
await handleInvoiceEvent(event.data.object, deps.stripeService)
if (event.type === 'invoice.payment_failed')
deps.metrics?.stripePaymentFailed.add(1)
if (event.type === 'invoice.paid' && event.data.object.amount_paid && event.data.object.currency) {
deps.metrics?.stripeRevenue.add(event.data.object.amount_paid, {
currency: event.data.object.currency,
source: 'invoice',
})
}
break
}
}
return { received: true }
}
}
async function handleCheckoutSessionCompleted(
stripeEventId: string,
session: Stripe.Checkout.Session,
fluxService: FluxService,
stripeService: StripeService,
billingService: BillingService,
): Promise<{ processed: boolean }> {
const userId = session.metadata?.userId
if (!userId) {
logger.withFields({ sessionId: session.id }).warn('Checkout session missing userId in metadata')
return { processed: false }
}
logger.withFields({ userId, sessionId: session.id, mode: session.mode, amount: session.amount_total, currency: session.currency }).log('Processing checkout session')
// Upsert customer record if we got a customer back.
if (session.customer) {
const stripeCustomerId = typeof session.customer === 'string' ? session.customer : session.customer.id
await stripeService.upsertCustomer({
userId,
stripeCustomerId,
email: session.customer_email ?? undefined,
})
await fluxService.updateStripeCustomerId(userId, stripeCustomerId)
}
await stripeService.upsertCheckoutSession({
userId,
stripeSessionId: session.id,
stripeCustomerId: typeof session.customer === 'string' ? session.customer : session.customer?.id,
mode: session.mode ?? 'payment',
status: session.status,
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
successUrl: session.success_url,
cancelUrl: session.cancel_url,
stripePaymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
stripeSubscriptionId: typeof session.subscription === 'string' ? session.subscription : session.subscription?.id,
metadata: session.metadata ? JSON.stringify(session.metadata) : null,
expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null,
})
// Idempotent flux credit: use fluxCredited flag inside a transaction
// to prevent double-crediting on webhook replay.
//
// For `payment` mode (one-time Flux purchase) `metadata.fluxAmount` is
// required — without it we can't credit anything, and the funnel must
// not see a `payment_completed` event for a checkout that didn't
// actually deliver Flux. Non-`payment` modes (e.g. `setup` for saving
// a card) deliberately skip crediting and still count as processed.
if (session.mode === 'payment') {
if (session.amount_total == null) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing amount_total; skipping credit and capture')
return { processed: false }
}
const metadataFlux = session.metadata?.fluxAmount
if (!metadataFlux) {
logger.withFields({ userId, sessionId: session.id }).warn('Payment-mode checkout missing metadata.fluxAmount; skipping credit and capture')
return { processed: false }
}
const fluxAmount = Number(metadataFlux)
if (!Number.isFinite(fluxAmount) || fluxAmount <= 0) {
logger.withFields({ userId, sessionId: session.id, metadataFlux }).warn('Invalid fluxAmount in session metadata, skipping credit')
return { processed: false }
}
const result = await billingService.creditFluxFromStripeCheckout({
stripeEventId,
userId,
stripeSessionId: session.id,
amountTotal: session.amount_total,
currency: session.currency,
fluxAmount,
})
logger.withFields({
userId,
fluxAmount,
amountTotal: session.amount_total,
applied: result.applied,
balanceAfter: result.balanceAfter,
}).log('Processed flux credit for one-time payment')
}
return { processed: true }
}
async function handleCustomerEvent(
customer: Stripe.Customer | Stripe.DeletedCustomer,
stripeService: StripeService,
) {
if (customer.deleted)
return
// Try to find existing customer to get userId.
const existing = await stripeService.getCustomerByStripeId(customer.id)
if (!existing)
return
await stripeService.upsertCustomer({
userId: existing.userId,
stripeCustomerId: customer.id,
email: customer.email ?? undefined,
name: customer.name ?? undefined,
})
}
async function handleSubscriptionEvent(
subscription: Stripe.Subscription,
stripeService: StripeService,
): Promise<StripeSubscriptionEventContext | null> {
const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return null
// In newer Stripe API, period info is on subscription items.
const firstItem = subscription.items.data[0]
await stripeService.upsertSubscription({
userId: customer.userId,
stripeSubscriptionId: subscription.id,
stripeCustomerId,
stripePriceId: firstItem?.price?.id,
status: subscription.status,
currentPeriodStart: firstItem?.current_period_start ? new Date(firstItem.current_period_start * 1000) : null,
currentPeriodEnd: firstItem?.current_period_end ? new Date(firstItem.current_period_end * 1000) : null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null,
metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null,
})
return {
userId: customer.userId,
stripeCustomerId,
stripeSubscriptionId: subscription.id,
stripePriceId: firstItem?.price?.id,
subscriptionStatus: subscription.status,
}
}
async function handleInvoiceEvent(
invoice: Stripe.Invoice,
stripeService: StripeService,
): Promise<StripeSubscriptionEventContext | null> {
const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!stripeCustomerId)
return null
const customer = await stripeService.getCustomerByStripeId(stripeCustomerId)
if (!customer)
return null
// In newer Stripe API, subscription is under parent.subscription_details.
const subDetails = invoice.parent?.subscription_details
const subscriptionId = subDetails
? (typeof subDetails.subscription === 'string' ? subDetails.subscription : subDetails.subscription?.id)
: undefined
await stripeService.upsertInvoice({
userId: customer.userId,
stripeInvoiceId: invoice.id,
stripeCustomerId,
stripeSubscriptionId: subscriptionId,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
invoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
periodStart: new Date(invoice.period_start * 1000),
periodEnd: new Date(invoice.period_end * 1000),
paidAt: invoice.status_transitions?.paid_at ? new Date(invoice.status_transitions.paid_at * 1000) : null,
metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null,
})
// TODO: implement subscription-based flux crediting when subscriptions are enabled
if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId)
logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented')
return {
userId: customer.userId,
stripeCustomerId,
stripeSubscriptionId: subscriptionId ?? '',
subscriptionStatus: invoice.status ?? undefined,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
}
}