Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625import { 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<typeof getWorkspaceByStripeId>[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<Db>, workspaceId: number) { const owners = await listWorkspaceOwners({ input: { workspaceId }, db }); return owners.map((owner) => owner.email);}
async function getBillingRecipients( db: NonNullable<Db>, 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<Db>; ws: NonNullable<Awaited<ReturnType<typeof getWorkspaceByStripeId>>>; 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<string | null> = []; 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); } }),});