Files
HiveGrid-Wireframes/hivegrid-cloud/apps/api/src/billing/stripe-webhook-route.ts
T

224 lines
8.7 KiB
TypeScript

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 = <T>(
fn: (tx: StripeWebhookTransactionClient) => Promise<T>,
) => Promise<T>;
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<unknown>;
};
export async function postStripeBillingWebhookRequest(c: Context<ApiErrorEnv>, 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<StripeBillingWebhookDependencies, "publishOutboxEvent">,
) {
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}`,
},
});
}
}