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
at incident-api
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497import { Events } from "@openstatus/analytics";import { workspacePlans } from "@openstatus/db/src/schema";import type { AddonQuantityKey, BillingInterval,} from "@openstatus/db/src/schema/plan/schema";import { addons, billingIntervals,} from "@openstatus/db/src/schema/plan/schema";import { isAddonQuantityKey } from "@openstatus/db/src/schema/plan/utils";import { ConflictError, type ServiceContext, countWorkspaceUsage,} from "@openstatus/services";import { getWorkspace, getWorkspaceForMember, updateWorkspaceLimits, updateWorkspacePlan, updateWorkspaceStripeId,} from "@openstatus/services/workspace";import { TRPCError } from "@trpc/server";import type Stripe from "stripe";import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../../trpc";import { buildFromSubscriptionOrThrow, getCurrentPeriodEnd, getCurrentSubscription, hasPaymentMethod, stripe, trialEndsAtOf,} from "./shared";import { buildPlanChangeItems, getPlanFromPriceId, getPriceIdForFeature, getPriceIdForPlan, resolveAddonQuantity,} from "./utils";
// The addon `title` reads wrong in the "you already have N ..." sentence.const LIMIT_LABEL: Record<AddonQuantityKey, string> = { monitors: "monitors", "status-pages": "status pages",};
const url = process.env.NODE_ENV === "production" ? "https://www.openstatus.dev" : "http://localhost:3000";
// The slug is input, so the target may differ from the active `ctx.workspace`.async function resolveWorkspaceCtx(opts: { ctx: { db: ServiceContext["db"]; user: { id: number } }; input: { workspaceSlug: string };}) { const access = await getWorkspaceForMember({ input: { slug: opts.input.workspaceSlug, userId: opts.ctx.user.id }, db: opts.ctx.db, }); if (!access) return; const ctx: ServiceContext = { workspace: access.workspace, actor: { type: "user", userId: opts.ctx.user.id }, db: opts.ctx.db, }; return { ctx, email: access.email };}
async function ensureStripeCustomer(ctx: ServiceContext, email: string | null) { if (ctx.workspace.stripeId) return ctx.workspace.stripeId;
const customer = await stripe.customers.create({ metadata: { workspaceId: String(ctx.workspace.id) }, email: email || "", });
try { await updateWorkspaceStripeId({ ctx, input: { stripeId: customer.id } }); } catch (err) { if (!(err instanceof ConflictError)) throw err; // A concurrent request linked its customer first; ours would orphan. await stripe.customers.del(customer.id).catch(() => undefined); const linked = await getWorkspace({ ctx }); if (!linked.stripeId) throw err; return linked.stripeId; }
return customer.id;}
async function createPaymentMethodSetupSession(args: { customer: string; subscriptionId: string; successUrl: string; cancelUrl: string;}) { return stripe.checkout.sessions.create({ mode: "setup", payment_method_types: ["card"], customer: args.customer, setup_intent_data: { metadata: { subscriptionId: args.subscriptionId } }, success_url: args.successUrl, cancel_url: args.cancelUrl, });}
export const stripeRouter = createTRPCRouter({ // The workspace only stores the plan, not the interval it is billed on, so // the plan table asks Stripe to tell "Pro monthly" apart from "Pro yearly". getBillingInterval: protectedProcedure.query( async ({ ctx }): Promise<BillingInterval | null> => { const stripeId = ctx.workspace.stripeId; if (!stripeId) return null;
const { current } = await getCurrentSubscription(stripeId); const planItem = current?.items.data.find((item) => getPlanFromPriceId(item.price.id), ); const interval = planItem?.price.recurring?.interval; if (interval === "year") return "yearly"; if (interval === "month") return "monthly"; return null; }, ),
getUserCustomerPortal: protectedProcedure .input( z.object({ workspaceSlug: z.string(), returnUrl: z.string().optional() }), ) .mutation(async (opts) => { const resolved = await resolveWorkspaceCtx(opts); if (!resolved) return; const result = resolved.ctx.workspace; const stripeId = await ensureStripeCustomer(resolved.ctx, resolved.email);
const session = await stripe.billingPortal.sessions.create({ customer: stripeId, return_url: opts.input.returnUrl || `${url}/app/${result.slug}/settings`, });
return session.url; }),
getCheckoutSession: protectedProcedure .input( z.object({ currency: z.string(), workspaceSlug: z.string(), plan: z.enum(workspacePlans), interval: z.enum(billingIntervals).default("monthly"), successUrl: z.string().optional(), cancelUrl: z.string().optional(), }), ) .mutation(async (opts) => { const resolved = await resolveWorkspaceCtx(opts); if (!resolved) return; const result = resolved.ctx.workspace; const stripeId = await ensureStripeCustomer(resolved.ctx, resolved.email);
const priceId = getPriceIdForPlan(opts.input.plan, opts.input.interval); if (!priceId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan", }); }
// A customer who already pays gets the plan swapped on the subscription // they have. Sending them back through checkout would open a *second* // subscription: both would bill until some unrelated webhook happened to // retire one, the addon line items would stay behind on the old one, and // the workspace would be rebuilt from a plan-only subscription โ silently // dropping every addon they bought. const { current } = await getCurrentSubscription(stripeId);
const billingUrl = `${url}/app/${result.slug}/settings/billing`; const isTrialing = current?.status === "trialing"; const trialWithoutCard = current !== undefined && isTrialing && !(await hasPaymentMethod(current));
if ( trialWithoutCard && opts.input.plan === "starter" && opts.input.interval === "monthly" ) { const session = await createPaymentMethodSetupSession({ customer: stripeId, subscriptionId: current.id, successUrl: opts.input.successUrl || `${billingUrl}?setup=true`, cancelUrl: opts.input.cancelUrl || billingUrl, }); return { type: "setup" as const, session }; }
// A trial without a card cannot be charged in place, so it goes through // a regular checkout; `sessionCompleted` retires the trial subscription. if (current && !trialWithoutCard) { const planItem = current.items.data.find((item) => getPlanFromPriceId(item.price.id), );
if (!planItem) { throw new TRPCError({ code: "BAD_REQUEST", message: "Your subscription is on a legacy price and cannot be changed here. Contact us and we will move it for you.", }); }
let items: Stripe.SubscriptionUpdateParams.Item[]; try { items = buildPlanChangeItems({ subscription: current, planItemId: planItem.id, planPriceId: priceId, interval: opts.input.interval, }); } catch { throw new TRPCError({ code: "BAD_REQUEST", message: "Your subscription has an add-on that cannot be moved to this billing interval. Contact us and we will switch it for you.", }); }
// Classify before mutating Stripe. An item on a price neither table // knows throws, and throwing *after* the update would leave the // customer re-priced and billed while the workspace kept the old plan // โ a split the webhook cannot repair either, since it throws on the // same item. buildFromSubscriptionOrThrow(current);
// Every existing item is listed by id, so Stripe re-prices it in place // instead of dropping it, and the addons survive the change. Clearing // `cancel_at_period_end` resumes a subscription the customer had // scheduled to cancel โ choosing a paid plan says they mean to keep // paying. const updated = await stripe.subscriptions.update(current.id, { items, proration_behavior: "create_prorations", cancel_at_period_end: false, ...(isTrialing && { trial_end: "now", payment_behavior: "error_if_incomplete", }), });
const built = buildFromSubscriptionOrThrow(updated);
if (!built) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan", }); }
// Sync here rather than waiting on `customer.subscription.updated`, so // the workspace the dashboard refetches is already correct. The webhook // rebuilds the same state from the same subscription, so applying it // again is a no-op. await updateWorkspacePlan({ ctx: resolved.ctx, input: { plan: built.plan, subscriptionId: updated.id, endsAt: getCurrentPeriodEnd(updated), paidUntil: getCurrentPeriodEnd(updated), trialEndsAt: trialEndsAtOf(updated), limits: built.limits, reason: "plan_changed", }, });
return { type: "updated" as const }; }
const session = await stripe.checkout.sessions.create({ payment_method_types: ["card"], currency: opts.input.currency, customer: stripeId, customer_update: { name: "auto", address: "auto", }, line_items: [ { price: priceId, quantity: 1, }, ], tax_id_collection: { enabled: true, }, mode: "subscription", success_url: opts.input.successUrl || `${billingUrl}?success=true`, cancel_url: opts.input.cancelUrl || billingUrl, });
return { type: "checkout" as const, session }; }),
getPaymentMethodSetupSession: protectedProcedure .input( z.object({ workspaceSlug: z.string(), successUrl: z.string().optional(), cancelUrl: z.string().optional(), }), ) .mutation(async (opts) => { const resolved = await resolveWorkspaceCtx(opts); const ws = resolved?.ctx.workspace; if (!ws?.stripeId) return;
const { current } = await getCurrentSubscription(ws.stripeId); if (!current) return;
const billingUrl = `${url}/app/${ws.slug}/settings/billing`; const session = await createPaymentMethodSetupSession({ customer: ws.stripeId, subscriptionId: current.id, successUrl: opts.input.successUrl || `${billingUrl}?setup=true`, cancelUrl: opts.input.cancelUrl || billingUrl, });
return session.url; }),
addAddon: protectedProcedure .meta({ track: Events.AddFeature, trackProps: ["feature"] }) .input( z .object({ workspaceSlug: z.string(), feature: z.enum(addons), value: z.union([z.boolean(), z.number()]), }) // A boolean on a quantity addon (or a number on a toggle) would change // the Stripe item but leave the limit untouched, so reject it up front. .refine( (i) => (typeof i.value === "number") === isAddonQuantityKey(i.feature), { message: "Value does not match the addon type", path: ["value"] }, ), ) .mutation(async (opts) => { const resolved = await resolveWorkspaceCtx(opts); if (!resolved) return; const ws = resolved.ctx.workspace;
const stripeId = ws.stripeId; if (!stripeId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Workspace has no Stripe ID", }); }
// Same "which subscription is current" rule as the plan change and the // webhooks. `customers.retrieve(expand: ["subscriptions"])` also returns // the `incomplete` records an abandoned checkout leaves behind, and // taking the first of those would attach the addon to a subscription // that never bills โ granting the limit for free until a later webhook // rebuilt it away. const { current } = await getCurrentSubscription(stripeId);
if (!current) { return; }
const isTrialing = current.status === "trialing"; const isRemoval = opts.input.value === false || opts.input.value === 0; // Removing an addon never ends the trial, so it needs no card. if (isTrialing && !isRemoval && !(await hasPaymentMethod(current))) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Add a payment method first.", }); }
const items = await stripe.subscriptionItems.list({ subscription: current.id, limit: 100, });
// Stripe rejects mixed billing intervals on one subscription, so the // addon is billed on the same interval as the plan. const planItem = items.data.find((item) => getPlanFromPriceId(item.price.id), ); const isYearly = planItem?.price.recurring?.interval === "year"; const priceId = getPriceIdForFeature( opts.input.feature, isYearly ? "yearly" : "monthly", );
if (!priceId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid feature", }); }
let quantity = 1; let newValue: boolean | number = opts.input.value;
if (typeof opts.input.value === "number") { let resolved: ReturnType<typeof resolveAddonQuantity>; try { resolved = resolveAddonQuantity({ addon: opts.input.feature, plan: ws.plan, packs: opts.input.value, }); } catch (e) { throw new TRPCError({ code: "BAD_REQUEST", message: e instanceof Error ? e.message : "Invalid quantity", }); }
quantity = resolved.quantity; newValue = resolved.newLimit;
if (!isAddonQuantityKey(opts.input.feature)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid feature", }); }
const current = await countWorkspaceUsage( opts.ctx.db, ws.id, opts.input.feature, ); if (current > resolved.newLimit) { throw new TRPCError({ code: "BAD_REQUEST", message: `You already have ${current} ${LIMIT_LABEL[opts.input.feature]}, please delete some first.`, }); } }
const item = items.data.find((item) => item.price.id === priceId);
// Charge the plan before granting the addon, so a declined card leaves // the trial and the limits untouched. const endsTrial = isTrialing && !isRemoval; if (endsTrial) { await stripe.subscriptions.update(current.id, { trial_end: "now", payment_behavior: "error_if_incomplete", }); }
if (isRemoval) { if (item) { await stripe.subscriptionItems.del(item.id); } } else if (item) { await stripe.subscriptionItems.update(item.id, { quantity, }); } else { await stripe.subscriptionItems.create({ price: priceId, subscription: current.id, quantity, }); }
await updateWorkspaceLimits({ ctx: resolved.ctx, input: { addon: opts.input.feature, value: newValue, ...(endsTrial && { trialEndsAt: null }), reason: endsTrial ? "trial_converted" : "addon_changed", }, });
// TODO: send email to user notifying about the change if not already from stripe
return; }),});