diff --git a/apps/dashboard/src/components/data-table/billing/data-table.tsx b/apps/dashboard/src/components/data-table/billing/data-table.tsx index b33042ca..c16c39d2 100644 --- a/apps/dashboard/src/components/data-table/billing/data-table.tsx +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -52,6 +52,9 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { const queryClient = useQueryClient(); const [isPending, startTransition] = useTransition(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: currentInterval } = useQuery( + trpc.stripeRouter.getBillingInterval.queryOptions(), + ); const checkoutSessionMutation = useMutation( trpc.stripeRouter.getCheckoutSession.mutationOptions({ @@ -62,9 +65,14 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { // already have, so there is no checkout to redirect to — only the // refreshed workspace to pick up. if (data.type === "updated") { - await queryClient.invalidateQueries({ - queryKey: trpc.workspace.get.queryKey(), - }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.workspace.get.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.stripeRouter.getBillingInterval.queryKey(), + }), + ]); toast.success("Your plan has been updated"); return; } @@ -112,9 +120,17 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { Features comparison {filteredPlans.map(({ id, ...plan }) => { - const isCurrentPlan = workspace.plan === id; - const price = getPriceConfig(id, currency, interval); const isFreePlan = id === "free"; + const isSamePlan = workspace.plan === id; + // Without a known interval (free plan, legacy price) fall back to + // matching on the plan alone. + const isCurrentPlan = + isSamePlan && + (isFreePlan || + !currentInterval || + currentInterval === interval); + const isIntervalSwitch = isSamePlan && !isCurrentPlan; + const price = getPriceConfig(id, currency, interval); return ( diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts index 35c9e443..a798bc76 100644 --- a/packages/api/src/router/stripe/index.ts +++ b/packages/api/src/router/stripe/index.ts @@ -7,7 +7,10 @@ import { workspace, workspacePlans, } from "@openstatus/db/src/schema"; -import type { AddonQuantityKey } from "@openstatus/db/src/schema/plan/schema"; +import type { + AddonQuantityKey, + BillingInterval, +} from "@openstatus/db/src/schema/plan/schema"; import { addons, billingIntervals, @@ -19,6 +22,7 @@ import { import { countWorkspaceUsage } from "@openstatus/services"; import { updateWorkspacePlan } from "@openstatus/services/workspace"; import { TRPCError } from "@trpc/server"; +import type Stripe from "stripe"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; @@ -29,6 +33,7 @@ import { stripe, } from "./shared"; import { + buildPlanChangeItems, getPlanFromPriceId, getPriceIdForFeature, getPriceIdForPlan, @@ -47,6 +52,24 @@ const url = : "http://localhost:3000"; 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 => { + 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() }), @@ -193,17 +216,19 @@ export const stripeRouter = createTRPCRouter({ }); } - // Stripe rejects mixed billing intervals on one subscription and every - // addon price is monthly, so a yearly plan cannot hold the addon items. - const hasAddons = current.items.data.some( - (item) => item.id !== planItem.id, - ); - - if (opts.input.interval === "yearly" && hasAddons) { + 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: - "Add-ons are billed monthly. Remove them before switching to a yearly plan, or contact us.", + "Your subscription has an add-on that cannot be moved to this billing interval. Contact us and we will switch it for you.", }); } @@ -214,13 +239,13 @@ export const stripeRouter = createTRPCRouter({ // same item. buildFromSubscriptionOrThrow(current); - // Only the plan item is listed, so Stripe leaves every other item - // untouched and the addons survive the plan change. Clearing + // 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: [{ id: planItem.id, price: priceId }], + items, proration_behavior: "create_prorations", cancel_at_period_end: false, }); @@ -339,30 +364,26 @@ export const stripeRouter = createTRPCRouter({ return; } - const priceId = getPriceIdForFeature(opts.input.feature); - - if (!priceId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid feature", - }); - } - const items = await stripe.subscriptionItems.list({ subscription: current.id, limit: 100, }); - // Stripe rejects mixed billing intervals on one subscription and every - // addon price is monthly, so a yearly plan cannot hold one. + // 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), ); - if (planItem?.price.recurring?.interval === "year") { + 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: - "Add-ons are billed monthly. Contact us to add them to a yearly plan.", + message: "Invalid feature", }); } diff --git a/packages/api/src/router/stripe/utils.test.ts b/packages/api/src/router/stripe/utils.test.ts index d130af44..54be41d5 100644 --- a/packages/api/src/router/stripe/utils.test.ts +++ b/packages/api/src/router/stripe/utils.test.ts @@ -10,6 +10,8 @@ import { FEATURES, PLANS, buildLimitsFromSubscription, + buildPlanChangeItems, + getPriceIdForFeature, resolveAddonQuantity, } from "./utils"; @@ -234,3 +236,116 @@ describe("resolveAddonQuantity", () => { ).toThrow(/whole number of packs/); }); }); + +describe("getPriceIdForFeature", () => { + test("defaults to the monthly price", () => { + expect(getPriceIdForFeature("status-pages")).toBe(STATUS_PAGES); + }); + + test("returns the yearly price when the addon has one", () => { + const yearly = getPriceIdForFeature("status-pages", "yearly"); + expect(yearly).toBeDefined(); + expect(yearly).not.toBe(STATUS_PAGES); + }); + + test("every addon has a yearly price", () => { + for (const { feature } of FEATURES) { + expect(getPriceIdForFeature(feature, "yearly")).toBeDefined(); + } + }); + + test("a yearly addon item still counts towards the limits", () => { + const built = buildLimitsFromSubscription( + subscriptionWith([ + { priceId: STARTER }, + { + priceId: getPriceIdForFeature("status-pages", "yearly"), + quantity: 2, + }, + ]), + ); + expect(built?.limits["status-pages"]).toBe( + getLimits("starter")["status-pages"] + + 2 * getAddonPackSize("status-pages"), + ); + }); +}); + +describe("buildPlanChangeItems", () => { + const yearlyPlanPrice = PLANS.find((p) => p.plan === "starter")?.price.yearly + .priceIds.test as string; + const monthlyFeature = (feature: string) => + FEATURES.find((f) => f.feature === feature)?.price.monthly.priceIds.test; + const yearlyFeature = (feature: string) => + FEATURES.find((f) => f.feature === feature)?.price.yearly.priceIds.test; + + function subscription( + items: { id: string; priceId: string | undefined; quantity?: number }[], + ) { + return { + items: { + data: items.map(({ id, priceId, quantity }) => ({ + id, + price: { id: priceId }, + quantity, + })), + }, + } as unknown as Stripe.Subscription; + } + + test("monthly → yearly moves the plan and every addon to yearly", () => { + const items = buildPlanChangeItems({ + subscription: subscription([ + { id: "si_plan", priceId: STARTER, quantity: 1 }, + ...FEATURES.map(({ feature }, i) => ({ + id: `si_${feature}`, + priceId: monthlyFeature(feature), + quantity: i + 1, + })), + ]), + planItemId: "si_plan", + planPriceId: yearlyPlanPrice, + interval: "yearly", + }); + + expect(items).toEqual([ + { id: "si_plan", price: yearlyPlanPrice }, + ...FEATURES.map(({ feature }, i) => ({ + id: `si_${feature}`, + price: yearlyFeature(feature), + quantity: i + 1, + })), + ]); + }); + + test("yearly → monthly moves every addon back to monthly", () => { + const items = buildPlanChangeItems({ + subscription: subscription([ + { id: "si_plan", priceId: yearlyPlanPrice }, + { id: "si_pages", priceId: yearlyFeature("status-pages"), quantity: 3 }, + ]), + planItemId: "si_plan", + planPriceId: STARTER as string, + interval: "monthly", + }); + + expect(items).toEqual([ + { id: "si_plan", price: STARTER }, + { id: "si_pages", price: STATUS_PAGES, quantity: 3 }, + ]); + }); + + test("throws on an addon item with an unknown price", () => { + expect(() => + buildPlanChangeItems({ + subscription: subscription([ + { id: "si_plan", priceId: STARTER }, + { id: "si_legacy", priceId: "price_legacy" }, + ]), + planItemId: "si_plan", + planPriceId: yearlyPlanPrice, + interval: "yearly", + }), + ).toThrow(); + }); +}); diff --git a/packages/api/src/router/stripe/utils.ts b/packages/api/src/router/stripe/utils.ts index fd9e8393..2ba1f666 100644 --- a/packages/api/src/router/stripe/utils.ts +++ b/packages/api/src/router/stripe/utils.ts @@ -15,6 +15,8 @@ import { } from "@openstatus/db/src/schema/plan/utils"; import type Stripe from "stripe"; +type PriceIds = { priceIds: { test: string; production: string } }; + /** * Rebuild a workspace's limits from the full set of subscription line items. * The plan item sets the baseline; each addon item then re-applies its flag or @@ -126,14 +128,47 @@ export const getPriceIdForPlan = ( return PLANS.find((p) => p.plan === plan)?.price[interval].priceIds[env]; }; -export const getPriceIdForFeature = (feature: keyof Addons) => { +export const getPriceIdForFeature = ( + feature: keyof Addons, + interval: BillingInterval = "monthly", +) => { const env = process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; - return FEATURES.find((f) => f.feature === feature)?.price.monthly.priceIds[ + return FEATURES.find((f) => f.feature === feature)?.price[interval].priceIds[ env ]; }; +/** + * The item list for a plan or interval change. Every item already on the + * subscription is listed by id so Stripe re-prices it in place instead of + * dropping it: the plan item takes the new plan price, and each addon moves to + * its price for the target interval with its quantity kept — Stripe rejects + * mixed intervals on one subscription, so a yearly plan takes yearly addons. + * Throws on an addon item whose price is unknown rather than dropping it. + */ +export function buildPlanChangeItems(args: { + subscription: Stripe.Subscription; + planItemId: string; + planPriceId: string; + interval: BillingInterval; +}): Stripe.SubscriptionUpdateParams.Item[] { + const { subscription, planItemId, planPriceId, interval } = args; + return subscription.items.data.map((item) => { + if (item.id === planItemId) return { id: item.id, price: planPriceId }; + const feature = getFeatureFromPriceId(item.price.id); + const price = feature + ? getPriceIdForFeature(feature.feature, interval) + : undefined; + if (!price) { + throw new Error( + `Unsupported Stripe price on subscription: ${item.price.id}`, + ); + } + return { id: item.id, price, quantity: item.quantity ?? 1 }; + }); +} + export const PLANS = [ { plan: "team", @@ -204,6 +239,12 @@ export const FEATURES = [ production: "price_1Sl6oqBXJcTfzsyJCxtzDIx5", }, }, + yearly: { + priceIds: { + test: "price_1UJ7bhBXJcTfzsyJhHuuQjr2", + production: "price_1UJ7bhBXJcTfzsyJhHuuQjr2", + }, + }, }, }, { @@ -212,7 +253,13 @@ export const FEATURES = [ monthly: { priceIds: { test: "price_1TMpxlBXJcTfzsyJ1woQtafW", - production: "price_1TMq0GBXJcTfzsyJrIVx9KPL", + production: "price_1TMpxlBXJcTfzsyJ1woQtafW", + }, + }, + yearly: { + priceIds: { + test: "price_1UJ7akBXJcTfzsyJzDrC8hdK", + production: "price_1UJ7akBXJcTfzsyJzDrC8hdK", }, }, }, @@ -226,6 +273,12 @@ export const FEATURES = [ production: "price_1SlbSdBXJcTfzsyJahJiFE8D", }, }, + yearly: { + priceIds: { + test: "price_1UJ74MBXJcTfzsyJ17ksU7wm", + production: "price_1UJ74MBXJcTfzsyJ17ksU7wm", + }, + }, }, }, { @@ -237,6 +290,12 @@ export const FEATURES = [ production: "price_1Tvv0zBXJcTfzsyJseLIjNnz", }, }, + yearly: { + priceIds: { + test: "price_1UJ76GBXJcTfzsyJ6BGJ3ZJd", + production: "price_1UJ76GBXJcTfzsyJ6BGJ3ZJd", + }, + }, }, }, { @@ -248,6 +307,12 @@ export const FEATURES = [ production: "price_1SlrkHBXJcTfzsyJIxHeKUYe", }, }, + yearly: { + priceIds: { + test: "price_1UJ70tBXJcTfzsyJRDDnbXcP", + production: "price_1UJ70tBXJcTfzsyJRDDnbXcP", + }, + }, }, }, { @@ -261,6 +326,12 @@ export const FEATURES = [ production: "price_1UChsCBXJcTfzsyJgomhUtYY", }, }, + yearly: { + priceIds: { + test: "price_1UJ7YkBXJcTfzsyJdklvON8r", + production: "price_1UJ7YkBXJcTfzsyJdklvON8r", + }, + }, }, }, { @@ -272,11 +343,20 @@ export const FEATURES = [ production: "price_1TySGYBXJcTfzsyJQFVFJi6N", }, }, + yearly: { + priceIds: { + test: "price_1UJ7ZcBXJcTfzsyJ78fA9RFp", + production: "price_1UJ7ZcBXJcTfzsyJ78fA9RFp", + }, + }, }, }, ] satisfies Array<{ feature: keyof Addons; + // Stripe rejects mixed intervals on one subscription, so every addon needs a + // price on each interval a plan is sold on. price: { - monthly: { priceIds: { test: string; production: string } }; + monthly: PriceIds; + yearly: PriceIds; }; }>;