From 48558a8a8de750819a7c7cd41cbcd35acde6ea26 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Thu, 24 Sep 2026 10:36:27 +0200 Subject: [PATCH] pr --- .../src/components/content/billing-addons.tsx | 49 +++++++++++---- .../data-table/billing/data-table.tsx | 62 ++++++++++++------- packages/db/src/schema/plan/utils.test.ts | 34 ++++++++++ packages/db/src/schema/plan/utils.ts | 10 ++- 4 files changed, 122 insertions(+), 33 deletions(-) create mode 100644 packages/db/src/schema/plan/utils.test.ts diff --git a/apps/dashboard/src/components/content/billing-addons.tsx b/apps/dashboard/src/components/content/billing-addons.tsx index aa0f966b..55f9cb7f 100644 --- a/apps/dashboard/src/components/content/billing-addons.tsx +++ b/apps/dashboard/src/components/content/billing-addons.tsx @@ -1,6 +1,9 @@ import type { RouterOutputs } from "@openstatus/api"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; -import type { Addons } from "@openstatus/db/src/schema/plan/schema"; +import type { + Addons, + BillingInterval, +} from "@openstatus/db/src/schema/plan/schema"; import { getAddonMaxQuantity, getAddonPackSize, @@ -23,7 +26,7 @@ import { ButtonGroup } from "@openstatus/ui/components/ui/button-group"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; @@ -61,6 +64,14 @@ export function BillingAddons({ const [currency] = useCookieState("x-currency", "USD"); const trpc = useTRPC(); const queryClient = useQueryClient(); + // Addons are billed on the plan's interval, so a yearly plan pays the + // yearly addon price. + // Until it resolves the price is unknown: showing the monthly one to a + // yearly customer would misstate what they are charged. + const intervalQuery = useQuery( + trpc.stripeRouter.getBillingInterval.queryOptions(), + ); + const interval: BillingInterval = intervalQuery.data ?? "monthly"; const checkoutSessionMutation = useMutation( trpc.stripeRouter.addAddon.mutationOptions({ onSuccess: () => { @@ -92,7 +103,9 @@ export function BillingAddons({ Math.floor((workspaceLimit - defaultLimit) / packSize) : workspaceLimit; const [value, setValue] = useState(defaultValue); - const price = getAddonPriceConfig(plan, addon, currency); + const price = intervalQuery.isSuccess + ? getAddonPriceConfig(plan, addon, currency, interval) + : null; // Reset value when modal opens useEffect(() => { @@ -161,8 +174,11 @@ export function BillingAddons({
- {formatPrice(price)} - {getPriceSuffix(isQuantity, packSize)} + {price + ? `${formatPrice(price)}${getPriceSuffix(isQuantity, packSize, interval)}` + : intervalQuery.isPending + ? "Loading..." + : "N/A"} {hasAddon && !isQuantity ? ( @@ -175,7 +191,7 @@ export function BillingAddons({
- @@ -193,6 +209,7 @@ export function BillingAddons({ hasAddon, packSize, unitLabel, + interval, )} {isTrialing && !isRemoval ? " Adding it ends your Starter trial and charges your card today." @@ -263,9 +280,18 @@ function getButtonLabel( return null; } -function getPriceSuffix(isQuantity: boolean, packSize: number) { - if (!isQuantity) return "/mo."; - return packSize > 1 ? `/mo./pack of ${packSize}` : "/mo./each"; +function getPeriodSuffix(interval: BillingInterval) { + return interval === "yearly" ? "/yr." : "/mo."; +} + +function getPriceSuffix( + isQuantity: boolean, + packSize: number, + interval: BillingInterval, +) { + const period = getPeriodSuffix(interval); + if (!isQuantity) return period; + return packSize > 1 ? `${period}/pack of ${packSize}` : `${period}/each`; } function getDialogDescription( @@ -275,11 +301,12 @@ function getDialogDescription( hasAddon: boolean, packSize: number, unitLabel: string, + interval: BillingInterval, ) { const formattedPrice = formatPrice(price); const isBoolean = typeof value === "boolean"; const isQuantity = typeof value === "number"; - const priceSuffix = getPriceSuffix(isQuantity, packSize); + const priceSuffix = getPriceSuffix(isQuantity, packSize, interval); if (isBoolean) { if (hasAddon) { @@ -293,7 +320,7 @@ function getDialogDescription( return `${label} will be removed from your subscription. You will stop being charged for it on your next billing cycle.`; } const total = formatAmount(price, price ? price.value * value : 0); - return `Your workspace will get ${value * packSize} extra ${unitLabel}. You will be charged ${total}/mo., starting on your next billing cycle.`; + return `Your workspace will get ${value * packSize} extra ${unitLabel}. You will be charged ${total}${getPeriodSuffix(interval)}, starting on your next billing cycle.`; } } 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 92600dc1..28b7e2aa 100644 --- a/apps/dashboard/src/components/data-table/billing/data-table.tsx +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -39,9 +39,14 @@ const BASE_URL = ? "https://app.openstatus.dev" : "http://localhost:3000"; -function getQuantitySuffix(addon: keyof Addons) { +function getPeriodSuffix(interval: BillingInterval) { + return interval === "yearly" ? "/yr." : "/mo."; +} + +function getQuantitySuffix(addon: keyof Addons, interval: BillingInterval) { const packSize = getAddonPackSize(addon); - return packSize > 1 ? `/mo./${packSize}` : "/mo./each"; + const period = getPeriodSuffix(interval); + return packSize > 1 ? `${period}/${packSize}` : `${period}/each`; } export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { @@ -52,9 +57,10 @@ 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( + const intervalQuery = useQuery( trpc.stripeRouter.getBillingInterval.queryOptions(), ); + const currentInterval = intervalQuery.data; const checkoutSessionMutation = useMutation( trpc.stripeRouter.getCheckoutSession.mutationOptions({ @@ -125,14 +131,24 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { {filteredPlans.map(({ id, ...plan }) => { 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. + // Hold the paid plan's button until the interval is known, so it + // never flashes "Current Plan" on the wrong tab. + const isIntervalLoading = + isSamePlan && !isFreePlan && intervalQuery.isPending; + // A resolved `null` (no subscription, legacy price) matches on the + // plan alone. A failed fetch leaves both intervals selectable: + // re-applying the price the customer already pays is a no-op. const isCurrentPlan = isSamePlan && (isFreePlan || - !currentInterval || - currentInterval === interval); - const isIntervalSwitch = isSamePlan && !isCurrentPlan; + (intervalQuery.isSuccess && + (currentInterval === null || + currentInterval === interval))); + const isIntervalSwitch = + isSamePlan && + intervalQuery.isSuccess && + currentInterval !== null && + currentInterval !== interval; const price = getPriceConfig(id, currency, interval); return ( - {isCurrentPlan - ? isTrialing - ? "On Trial" - : "Current Plan" - : isPending - ? "Choosing..." - : isIntervalSwitch - ? `Switch to ${interval}` - : isTrialing && !isFreePlan - ? "Upgrade now" - : "Choose"} + {isIntervalLoading + ? "Loading..." + : isCurrentPlan + ? isTrialing + ? "On Trial" + : "Current Plan" + : isPending + ? "Choosing..." + : isIntervalSwitch + ? `Switch to ${interval}` + : isTrialing && !isFreePlan + ? "Upgrade now" + : "Choose"}
@@ -251,6 +269,7 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { plan.id, value as keyof typeof plan.addons, currency, + interval, ); if (!price) return null; @@ -276,8 +295,9 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { {isNumber ? getQuantitySuffix( value as keyof typeof plan.addons, + interval, ) - : "/mo."} + : getPeriodSuffix(interval)} diff --git a/packages/db/src/schema/plan/utils.test.ts b/packages/db/src/schema/plan/utils.test.ts new file mode 100644 index 00000000..620af10c --- /dev/null +++ b/packages/db/src/schema/plan/utils.test.ts @@ -0,0 +1,34 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { allPlans } from "./config"; +import { YEARLY_ADDON_MULTIPLIER, getAddonPriceConfig } from "./utils"; + +describe("getAddonPriceConfig", () => { + it("defaults to the monthly price", () => { + const monthly = getAddonPriceConfig("starter", "white-label", "USD"); + expect(monthly?.value).toBe( + allPlans.starter.addons["white-label"]?.price.USD, + ); + }); + + it("charges the yearly price on a yearly plan", () => { + const monthly = getAddonPriceConfig("starter", "status-pages", "EUR"); + const yearly = getAddonPriceConfig( + "starter", + "status-pages", + "EUR", + "yearly", + ); + expect(yearly).toEqual({ + ...monthly, + value: (monthly?.value ?? 0) * YEARLY_ADDON_MULTIPLIER, + }); + }); + + it("returns null for an addon the plan does not sell", () => { + expect(getAddonPriceConfig("free", "white-label", "USD", "yearly")).toBe( + null, + ); + }); +}); diff --git a/packages/db/src/schema/plan/utils.ts b/packages/db/src/schema/plan/utils.ts index 01542543..1e6c3911 100644 --- a/packages/db/src/schema/plan/utils.ts +++ b/packages/db/src/schema/plan/utils.ts @@ -86,16 +86,24 @@ export function getAddonMaxQuantity(addon: keyof Addons): number | null { return getAddonQuantityConfig(addon)?.maxQuantity ?? null; } +// Yearly addon prices in Stripe are the monthly price ×10 — the same "2 months +// free" as the plans — so they are derived rather than stored per addon. +export const YEARLY_ADDON_MULTIPLIER = 10; + export function getAddonPriceConfig( plan: WorkspacePlan, addon: keyof Addons, currency?: string, + interval: BillingInterval = "monthly", ) { const addonConfig = allPlans[plan].addons[addon]; if (!addonConfig) { return null; } - return resolvePriceConfig(addonConfig.price, currency); + const price = resolvePriceConfig(addonConfig.price, currency); + return interval === "yearly" + ? { ...price, value: price.value * YEARLY_ADDON_MULTIPLIER } + : price; } export function getPlansForLimit( -- 2.51.2