import { Events, setupAnalytics } from "@openstatus/analytics"; import { eq } from "@openstatus/db"; import { user } from "@openstatus/db/src/schema"; import { billingRecipients, cancelScheduledEmail, schedulePlanEndingSoon, sendCancellationScheduled, sendMemberRemoved, sendPlanDowngraded, sendTrialEnding, stripeIdempotencyKey, } from "@openstatus/emails"; import type { ServiceContext } from "@openstatus/services"; import { type DowngradeTrim, downgradeWorkspaceToFree, getWorkspaceByStripeId, listWorkspaceOwners, previewWorkspaceDowngrade, updateWorkspacePlan, } from "@openstatus/services/workspace"; import { TRPCError } from "@trpc/server"; import type Stripe from "stripe"; import { z } from "zod"; import { removeDomainFromVercelIfUnused } from "../../lib/vercel"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { buildFromSubscriptionOrThrow, cancelSupersededSubscriptions, customerIdOf, getCurrentPeriodEnd, getCurrentSubscription, hasPaymentMethod, isNewerSubscription, listLiveSubscriptions, stripe, trialEndsAtOf, } from "./shared"; const webhookProcedure = publicProcedure.input( z.object({ // From type Stripe.Event event: z.object({ id: z.string(), account: z.string().nullish(), created: z.number(), data: z.object({ object: z.record(z.string(), z.any()), previous_attributes: z.record(z.string(), z.any()).optional(), }), type: z.string(), }), }), ); // Stripe subscription metadata key holding the Resend id of the scheduled // "plan ends in 3 days" reminder, so a resume can cancel it without a schema // change. const REMINDER_METADATA_KEY = "reminder_email_id"; type Db = Parameters[0]["db"]; // Stripe cancels a trial without a card at `trial_end`, but its cycle // processing can lag behind that timestamp by a little. const TRIAL_END_SLACK_S = 60 * 60; function endedDuringTrial( subscription: Stripe.Subscription, eventCreated: number, ) { if (!subscription.trial_end) return false; const endedAt = subscription.ended_at ?? subscription.canceled_at ?? eventCreated; return endedAt <= subscription.trial_end + TRIAL_END_SLACK_S; } async function getOwnerEmails(db: NonNullable, workspaceId: number) { const owners = await listWorkspaceOwners({ input: { workspaceId }, db }); return owners.map((owner) => owner.email); } async function getBillingRecipients( db: NonNullable, workspaceId: number, customerId: string, ) { const owners = await getOwnerEmails(db, workspaceId); const customer = await stripe.customers.retrieve(customerId); return billingRecipients(owners, customer.deleted ? null : customer.email); } function toPlanLoss( trim: DowngradeTrim, customDomains: string[], sso: boolean, ) { return { monitorsDeactivated: trim.monitorsDeactivated, pagesDeleted: trim.pagesDeleted, keptPageTitle: trim.keptPageTitle, notificationsDeleted: trim.notificationsDeleted, invitationsDeleted: trim.invitationsDeleted, // the count, not the notifiable emails: members without one still left membersRemoved: trim.membersRemovedCount, customDomains, sso, }; } async function sendCancellationEmails(args: { db: NonNullable; ws: NonNullable>>; customerId: string; current: Stripe.Subscription; plan: string; eventId: string; }) { const { db, ws, customerId, current, plan, eventId } = args; const endsAt = getCurrentPeriodEnd(current); const to = await getBillingRecipients(db, ws.id, customerId); const preview = await previewWorkspaceDowngrade({ ctx: { workspace: ws, actor: { type: "system", job: "stripe-subscription-updated" }, db, }, }); const loss = toPlanLoss(preview, preview.customDomains, preview.ssoEnabled); // Independent: a failed confirmation must not suppress the reminder. try { await sendCancellationScheduled({ to, eventId, plan, endsAt, loss }); } catch (err) { console.error("Failed to send cancellation confirmation:", err); } const reminderId = await schedulePlanEndingSoon({ to, eventId, workspaceSlug: ws.slug, plan, endsAt, loss, }); if (!reminderId) return; const updated = await stripe.subscriptions.update(current.id, { metadata: { [REMINDER_METADATA_KEY]: reminderId }, }); // A resume handled while we were scheduling found no id to cancel. The // update response is the state after our write, so undo the reminder here. if ( !updated.cancel_at_period_end && (await cancelScheduledEmail(reminderId)) ) { await stripe.subscriptions.update(current.id, { metadata: { [REMINDER_METADATA_KEY]: "" }, }); } } async function attachSetupPaymentMethod(session: Stripe.Checkout.Session) { if (typeof session.setup_intent !== "string" || !session.customer) return; const customerId = typeof session.customer === "string" ? session.customer : session.customer.id; const setupIntent = await stripe.setupIntents.retrieve(session.setup_intent); const paymentMethod = typeof setupIntent.payment_method === "string" ? setupIntent.payment_method : setupIntent.payment_method?.id; if (!paymentMethod) return; await stripe.customers.update(customerId, { invoice_settings: { default_payment_method: paymentMethod }, }); const subscriptionId = setupIntent.metadata?.subscriptionId || (await getCurrentSubscription(customerId)).current?.id; if (!subscriptionId) return; const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (customerIdOf(subscription) !== customerId) return; await stripe.subscriptions.update(subscriptionId, { default_payment_method: paymentMethod, }); } // Never mount this on an app router: the procedures trust `event`, and only // the signature-verifying HTTP route may call them. export const webhookRouter = createTRPCRouter({ customerSubscriptionUpdated: webhookProcedure.mutation(async (opts) => { const eventSubscription = opts.input.event.data .object as Stripe.Subscription; const customerId = typeof eventSubscription.customer === "string" ? eventSubscription.customer : eventSubscription.customer.id; // Deliberately built from Stripe's live state rather than from // `event.data.object`: Stripe guarantees neither delivery order nor // exactly-once delivery, so a late or duplicated event would otherwise // replay an outdated item set — re-enabling an addon the customer just // removed, or dropping one they just bought. Re-reading makes every // delivery converge on the same result. It also keeps // `current_period_end` trustworthy, which the raw payload is not: it is // serialised with the API version pinned on the Stripe *endpoint*, and // newer versions moved that field onto the subscription items. const { live, current } = await getCurrentSubscription(customerId); // Nothing live left — `customer.subscription.deleted` owns the downgrade. if (!current) { return; } const ws = await getWorkspaceByStripeId({ input: { stripeId: customerId }, db: opts.ctx.db, }); if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", }); } const oldPlan = ws.plan; const built = buildFromSubscriptionOrThrow(current); // Subscription has no recognized plan item (e.g. a standalone addon sub); // nothing to sync here, unlike sessionCompleted which always has a plan. // Bail before cancelling anything: if the newest subscription is not one // we can classify, the plan may well be carried by an older one, and // retiring that would leave the workspace paying for nothing. if (!built) { return; } // The workspace follows the newest active subscription; anything older is // a leftover from a plan change that went through checkout. Cancelling by // age rather than by "whichever subscription this event named" is what // stops a stale event from retiring the subscription the customer is // actually on. await cancelSupersededSubscriptions(live, current); // No `reason` metadata: `customer.subscription.updated` fires on trivial // changes too, so let the audit no-op-skip drop rows where nothing // tracked changed. The `stripe-subscription-updated` actor id still // identifies the source on the rows that do land. await updateWorkspacePlan({ ctx: { workspace: ws, actor: { type: "system", job: "stripe-subscription-updated" }, db: opts.ctx.db, }, input: { plan: built.plan, subscriptionId: current.id, endsAt: getCurrentPeriodEnd(current), paidUntil: getCurrentPeriodEnd(current), trialEndsAt: trialEndsAtOf(current), limits: built.limits, }, }); // Best-effort: the one place the raw event is read instead of live state. // The mail is keyed by event id, so a replay cannot re-send, and our own // metadata write below fires an update whose `previous_attributes` holds // only `metadata`, so it is ignored here. const wasCancelling = opts.input.event.data.previous_attributes?.cancel_at_period_end; const isCancelling = current.cancel_at_period_end; // The event must carry a real flip, and live state must agree with it: a // late cancel event after a resume (or the reverse) is stale and ignored. if ( eventSubscription.id === current.id && typeof wasCancelling === "boolean" && wasCancelling !== eventSubscription.cancel_at_period_end && eventSubscription.cancel_at_period_end === isCancelling ) { try { if (isCancelling) { await sendCancellationEmails({ db: opts.ctx.db, ws, customerId, current, plan: built.plan, eventId: opts.input.event.id, }); } else { const reminderId = current.metadata?.[REMINDER_METADATA_KEY]; // Keep the id when the cancel fails, so a retry can still cancel it. if (reminderId && (await cancelScheduledEmail(reminderId))) { await stripe.subscriptions.update(current.id, { metadata: { [REMINDER_METADATA_KEY]: "" }, }); } } } catch (err) { console.error("Failed to handle cancellation emails:", err); } } const wasTrialing = opts.input.event.data.previous_attributes?.status === "trialing"; if ( eventSubscription.id === current.id && wasTrialing && current.status === "active" ) { const [owner] = await listWorkspaceOwners({ input: { workspaceId: ws.id }, db: opts.ctx.db, }); const analytics = await setupAnalytics({ userId: owner ? `usr_${owner.id}` : undefined, email: owner?.email ?? undefined, workspaceId: String(ws.id), plan: built.plan, }); await analytics.track(Events.ConvertTrial); } const newPlan = built.plan; if (newPlan !== oldPlan) { const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { const userResult = await opts.ctx.db .select() .from(user) .where(eq(user.email, customer.email)) .get(); if (!userResult) return; const planOrder = ["free", "starter", "team", "scale"] as const; const oldIndex = planOrder.indexOf(oldPlan ?? "free"); const newIndex = planOrder.indexOf(newPlan ?? "free"); const event = newIndex > oldIndex ? Events.UpgradeWorkspace : Events.DowngradeWorkspace; const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: userResult.email || undefined, workspaceId: String(ws.id), plan: newPlan, }); await analytics.track(event); } } }), sessionCompleted: webhookProcedure.mutation(async (opts) => { const session = opts.input.event.data.object as Stripe.Checkout.Session; if (session.mode === "setup") { await attachSetupPaymentMethod(session); return; } if (typeof session.subscription !== "string") { throw new TRPCError({ code: "BAD_REQUEST", message: "Missing or invalid subscription id", }); } const subscription = await stripe.subscriptions.retrieve( session.subscription, ); const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id; const ws = await getWorkspaceByStripeId({ input: { stripeId: customerId }, db: opts.ctx.db, }); if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", }); } // A replayed or late `checkout.session.completed` can name a subscription // that a newer one has already superseded. Writing it would move the // workspace back to the older plan while the newer subscription keeps // billing, so leave the workspace to that subscription's own events. const { live, current } = await getCurrentSubscription(customerId); if (current && isNewerSubscription(current, subscription)) { return; } const built = buildFromSubscriptionOrThrow(subscription); if (!built) { console.error("Invalid plan"); throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan", }); } // Checkout always opens a new subscription, so anything else still active // predates it and would keep billing. Retire it here instead of waiting // for an unrelated `customer.subscription.updated` to come along. await cancelSupersededSubscriptions(live, subscription); await updateWorkspacePlan({ ctx: { workspace: ws, actor: { type: "system", job: "stripe-session-completed" }, db: opts.ctx.db, }, input: { plan: built.plan, subscriptionId: subscription.id, endsAt: getCurrentPeriodEnd(subscription), paidUntil: getCurrentPeriodEnd(subscription), trialEndsAt: trialEndsAtOf(subscription), limits: built.limits, reason: "checkout_session_completed", }, }); const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { const userResult = await opts.ctx.db .select() .from(user) .where(eq(user.email, customer.email)) .get(); if (!userResult) return; const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: userResult.email || undefined, workspaceId: String(ws.id), plan: built.plan, }); await analytics.track(Events.UpgradeWorkspace); } }), customerSubscriptionTrialWillEnd: webhookProcedure.mutation(async (opts) => { const subscription = opts.input.event.data.object as Stripe.Subscription; const customerId = customerIdOf(subscription); // Stripe also fires this when a trial is cut short with `trial_end: now`, // which is how a mid-trial upgrade starts billing — no reminder then. if ( !subscription.trial_end || subscription.status !== "trialing" || subscription.trial_end <= opts.input.event.created ) { return; } const ws = await getWorkspaceByStripeId({ input: { stripeId: customerId }, db: opts.ctx.db, }); if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", }); } try { const withCard = await hasPaymentMethod(subscription); const preview = withCard ? undefined : await previewWorkspaceDowngrade({ ctx: { workspace: ws, actor: { type: "system", job: "stripe-trial-will-end" }, db: opts.ctx.db, }, }); await sendTrialEnding({ to: await getBillingRecipients(opts.ctx.db, ws.id, customerId), eventId: opts.input.event.id, workspaceSlug: ws.slug, trialEnd: new Date(subscription.trial_end * 1000), plan: ws.plan ?? "starter", hasPaymentMethod: withCard, loss: preview ? toPlanLoss(preview, preview.customDomains, preview.ssoEnabled) : undefined, }); } catch (err) { console.error("Failed to send trial ending email:", err); } }), customerSubscriptionDeleted: webhookProcedure.mutation(async (opts) => { const subscription = opts.input.event.data.object as Stripe.Subscription; const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id; // Only the customer's *last* subscription going away is a downgrade. This // event also fires for a subscription we retired ourselves as superseded, // and for one Stripe cancelled after dunning while another still stands — // in both cases the customer is still subscribed and the cascade below // would be destructive. const live = await listLiveSubscriptions(customerId); if (live.length > 0) { return; } const ws = await getWorkspaceByStripeId({ input: { stripeId: customerId }, db: opts.ctx.db, }); if (!ws) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace not found", }); } // Already downgraded, e.g. a trial cancelled by an account deletion. if (ws.plan === "free" && !ws.subscriptionId) { return; } // System actor — no user is attributable to an involuntary Stripe // cancellation. The service verb runs the whole trim in one audited // transaction; a failed audit insert rolls the downgrade back and the // webhook returns non-2xx so Stripe retries. const ctx: ServiceContext = { workspace: ws, actor: { type: "system", job: "stripe-subscription-deleted" }, db: opts.ctx.db, }; // A trial that ran out without a card, or was cancelled mid-trial, is // not a paying customer churning — keep the audit trail honest. Read // off the subscription rather than `ws.trialEndsAt`: a delayed // trial-to-active webhook leaves that marker stale on a paying customer. const reason = endedDuringTrial(subscription, opts.input.event.created) ? subscription.cancellation_details?.reason === "cancellation_requested" ? "trial_cancelled" : "trial_ended" : "subscription_deleted"; const { customDomains, ssoDisabled, trimmed } = await downgradeWorkspaceToFree({ ctx, input: { reason } }); // Best-effort after commit: owners must know what the cascade removed, and // removed members that they lost access, but a mail failure must not fail // the webhook into Stripe retries — nor one mail suppress the other. const eventId = opts.input.event.id; let owners: Array = []; try { owners = await getOwnerEmails(opts.ctx.db, ws.id); const customer = await stripe.customers.retrieve(customerId); await sendPlanDowngraded({ to: billingRecipients(owners, customer.deleted ? null : customer.email), eventId, workspaceSlug: ws.slug, previousPlan: ws.plan ?? "paid", loss: toPlanLoss(trimmed, customDomains, ssoDisabled), }); } catch (err) { console.error("Failed to send plan-downgraded email:", err); } try { await sendMemberRemoved({ to: trimmed.membersRemoved, idempotencyKey: stripeIdempotencyKey(eventId, "member-removed"), workspaceName: ws.name || ws.slug, reason: "downgrade", owners: billingRecipients(owners), }); } catch (err) { console.error("Failed to send member-removed emails:", err); } // Free plan has no custom-domain feature — release each domain on Vercel // unless another workspace's page still holds it. Best-effort after // commit: a Vercel error must not fail the webhook into Stripe retries. for (const domain of customDomains) { try { await removeDomainFromVercelIfUnused(opts.ctx.db, domain); } catch (err) { console.error("Failed to release domain from Vercel:", { domain, error: err, }); } } const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { const userResult = await opts.ctx.db .select() .from(user) .where(eq(user.email, customer.email)) .get(); if (!userResult) return; const analytics = await setupAnalytics({ userId: `usr_${userResult.id}`, email: customer.email || undefined, workspaceId: String(ws.id), plan: "free", }); await analytics.track(Events.DowngradeWorkspace); } }), });