224 lines
8.7 KiB
TypeScript
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}`,
|
|
},
|
|
});
|
|
}
|
|
}
|