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;
};
}>;