import type { StripeTenantSubscriptionProduct, StripeTenantSubscriptionProductClient } from "@toolctrl/db/subscriptions.server"; import type { OutboxPublisherTransaction, OutboxPublishArgs } from "@toolctrl/db/outbox"; import type { Context } from "hono"; import type Stripe from "stripe"; import type { ApiAppDependencies } from "../app.js"; import { getApiRuntimeConfig } from "../config.js"; import { apiLogger } from "../logger.js"; import { getApiMetrics } from "../metrics.js"; import { apiError, type ApiErrorEnv } from "../middleware/error-envelope.js"; import { publishSubscriptionInvalidation } from "../cache/subscription-cache-publisher.js"; function recordStripeWebhookOutcome(eventType: string, status: "received" | "invalid_signature" | "missing_signature" | "config_unavailable") { try { getApiMetrics().metrics.stripeWebhookEventsTotal.labels({ event_type: eventType, status }).inc(); } catch { // Metric writes must never break webhook handling. } } /** * Transaction runner contract. Production wires this to * `prisma.$transaction(fn)` so the subscription update + outbox publish * land atomically (Phase 03 #42). The transaction client must satisfy * both `StripeTenantSubscriptionProductClient` (for the subscription * update) and `OutboxPublisherTransaction` (for outbox publishes). */ export type StripeWebhookTransactionClient = StripeTenantSubscriptionProductClient & OutboxPublisherTransaction; export type StripeWebhookTransactionRunner = ( fn: (tx: StripeWebhookTransactionClient) => Promise, ) => Promise; type StripeBillingWebhookDependencies = Pick< ApiAppDependencies, | "constructStripeWebhookEvent" | "retrieveStripeSubscription" | "findTenantSubscriptionProductByStripeSubscriptionId" | "updateTenantSubscriptionProductFromStripe" > & { /** * Runs the subscription update + outbox publish in a single Prisma * transaction (Phase 03 #42). Production wiring lives in * `app-dependencies.ts`. */ runStripeWebhookTransaction: StripeWebhookTransactionRunner; /** * Outbox publisher bound against the transaction client. Defaults to * `publishOutboxEvent` from `@toolctrl/db/outbox`; tests inject a * recorder. Wrapped here as a dependency so we never reach for the * top-level Prisma singleton inside the transaction. */ publishOutboxEvent: (tx: OutboxPublisherTransaction, args: OutboxPublishArgs) => Promise; }; export async function postStripeBillingWebhookRequest(c: Context, dependencies: StripeBillingWebhookDependencies) { const config = getApiRuntimeConfig(); if (!config.stripe.secretKey || !config.stripe.webhookSigningSecret) { recordStripeWebhookOutcome("unknown", "config_unavailable"); return apiError(c, 503, "dependency_unavailable", "Stripe billing webhook configuration is unavailable."); } const signature = c.req.header("stripe-signature"); if (!signature) { recordStripeWebhookOutcome("unknown", "missing_signature"); return apiError(c, 400, "invalid_request", "Missing Stripe signature."); } const payload = await c.req.text(); let event: Stripe.Event; try { event = dependencies.constructStripeWebhookEvent(payload, signature); } catch { recordStripeWebhookOutcome("unknown", "invalid_signature"); return apiError(c, 400, "invalid_signature", "Invalid Stripe webhook signature."); } if ( event.type === "subscription_schedule.canceled" || event.type === "customer.subscription.deleted" || event.type === "customer.subscription.updated" ) { const stripeSubscriptionId = getStripeSubscriptionIdFromEvent(event); if (stripeSubscriptionId) { await syncStripeTenantSubscription(stripeSubscriptionId, event.id, dependencies); } } recordStripeWebhookOutcome(event.type, "received"); return c.json({ received: true, event: event.type }); } function getStripeSubscriptionIdFromEvent(event: Stripe.Event): string | null { const object = event.data.object as { id?: string; subscription?: string | { id?: string } | null }; if (event.type === "subscription_schedule.canceled") { if (typeof object.subscription === "string") return object.subscription; return object.subscription?.id ?? null; } return object.id ?? null; } async function syncStripeTenantSubscription( stripeSubscriptionId: string, stripeEventId: string, dependencies: StripeBillingWebhookDependencies, ) { const stripeSubscription = await dependencies.retrieveStripeSubscription(stripeSubscriptionId); if (!stripeSubscription) { return; } const tenantSubscriptionProduct = await dependencies.findTenantSubscriptionProductByStripeSubscriptionId(stripeSubscription.id); if (!tenantSubscriptionProduct) { return; } const previousEndsAt = tenantSubscriptionProduct.endsAt?.getTime() ?? null; const previousCancelledAt = tenantSubscriptionProduct.cancelledAt?.getTime() ?? null; let endsAt: Date | null = null; let cancelledAt: Date | null = null; if (stripeSubscription.cancel_at) { endsAt = new Date(stripeSubscription.cancel_at * 1000); cancelledAt = new Date(stripeSubscription.cancel_at * 1000); } else if (stripeSubscription.canceled_at) { cancelledAt = new Date(stripeSubscription.canceled_at * 1000); endsAt = stripeSubscription.ended_at ? new Date(stripeSubscription.ended_at * 1000) : new Date(); } // Phase 03 #42 — wrap the DB update + outbox publishes in a single // transaction. The legacy in-process `createLegacyEvent` write moved // into a receiver worker (apps/worker/src/jobs/legacy-event-receiver.ts) // that consumes the published events from the `outbox.notification` // BullMQ queue. A crash between commit and dispatcher pickup is now // safe: the events stay PENDING and the dispatcher claims them on // the next tick. await dependencies.runStripeWebhookTransaction(async (tx) => { await dependencies.updateTenantSubscriptionProductFromStripe( tenantSubscriptionProduct.id, { cancelledAt, endsAt, currentPeriodStart: new Date((stripeSubscription.items.data[0]?.current_period_start ?? 0) * 1000), currentPeriodEnd: new Date((stripeSubscription.items.data[0]?.current_period_end ?? 0) * 1000), }, tx, ); await publishSubscriptionLifecycleEvents( { tenantSubscriptionProduct, endsAt, cancelledAt, previousEndsAt, previousCancelledAt, stripeEventId, }, tx, dependencies, ); }); // Cross-replica cache invalidation: publish so every web replica drops // its in-process subscription cache for this tenant. Fire-and-forget; // failures must not block webhook delivery (5xx → Stripe retries). const tenantIdToInvalidate = tenantSubscriptionProduct.tenantSubscription.tenant.id; publishSubscriptionInvalidation(tenantIdToInvalidate).catch((err: unknown) => { apiLogger.warn({ err, tenantId: tenantIdToInvalidate }, "stripe-webhook: failed to publish subscription invalidation"); }); } async function publishSubscriptionLifecycleEvents( { tenantSubscriptionProduct, endsAt, cancelledAt, previousEndsAt, previousCancelledAt, stripeEventId, }: { tenantSubscriptionProduct: StripeTenantSubscriptionProduct; endsAt: Date | null; cancelledAt: Date | null; previousEndsAt: number | null; previousCancelledAt: number | null; stripeEventId: string; }, tx: StripeWebhookTransactionClient, dependencies: Pick, ) { const tenant = tenantSubscriptionProduct.tenantSubscription.tenant; const product = tenantSubscriptionProduct.subscriptionProduct; if (endsAt && previousEndsAt !== endsAt.getTime()) { await dependencies.publishOutboxEvent(tx, { tenantId: tenant.id, type: "billing.subscription.ended", // Idempotent against duplicate Stripe deliveries: each Stripe event // is identified by `evt_*`; combined with the lifecycle suffix this // dedups re-deliveries of the same webhook event. dispatchKey: `stripe:${stripeEventId}:subscription.ended`, payload: { name: "subscription.ended", tenantId: tenant.id, userId: null, data: JSON.stringify({ tenant, product }), description: `${tenant.name} subscription ended: ${product.title}`, }, }); } if (cancelledAt && previousCancelledAt !== cancelledAt.getTime()) { await dependencies.publishOutboxEvent(tx, { tenantId: tenant.id, type: "billing.subscription.cancelled", dispatchKey: `stripe:${stripeEventId}:subscription.cancelled`, payload: { name: "subscription.cancelled", tenantId: tenant.id, userId: null, data: JSON.stringify({ tenant, product }), description: `${tenant.name} subscription cancelled: ${product.title}`, }, }); } }