import type { RouterOutputs } from "@openstatus/api"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Addons, BillingInterval, } from "@openstatus/db/src/schema/plan/schema"; import { getAddonMaxQuantity, getAddonPackSize, getAddonPriceConfig, } from "@openstatus/db/src/schema/plan/utils"; import { Check, Remove, Add } from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; 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, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; import { useTRPC } from "@/lib/trpc/client"; type Workspace = RouterOutputs["workspace"]["get"]; interface BillingAddonsProps { label: string; description: React.ReactNode; addon: keyof Addons; workspace: Workspace; } interface PriceConfig { value: number; currency: string; locale: string; } const QUANTITY_UNIT_LABEL: Partial> = { monitors: "monitors", "status-pages": "status pages", }; export function BillingAddons({ label, description, addon, workspace, }: BillingAddonsProps) { const [open, setOpen] = useState(false); const [isPending, startTransition] = useTransition(); 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: () => { queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); }, }), ); const paymentMethodSetupMutation = useMutation( trpc.stripeRouter.getPaymentMethodSetupSession.mutationOptions({ onSuccess: (url) => { if (url) window.location.assign(url); }, onError: (error) => toast.error(error.message), }), ); const isTrialing = workspace.trialDaysLeft !== null; const plan = workspace.plan; const packSize = getAddonPackSize(addon); const maxPacks = getAddonMaxQuantity(addon); const unitLabel = QUANTITY_UNIT_LABEL[addon] ?? "units"; const defaultLimit = allPlans[workspace.plan].limits[addon]; const workspaceLimit = workspace.limits[addon]; const defaultValue = typeof workspaceLimit === "number" && typeof defaultLimit === "number" ? // packs held today; floored so a hand-edited limit that is not a whole // number of packs still renders a sane count Math.floor((workspaceLimit - defaultLimit) / packSize) : workspaceLimit; const [value, setValue] = useState(defaultValue); const price = intervalQuery.isSuccess ? getAddonPriceConfig(plan, addon, currency, interval) : null; // Reset value when modal opens useEffect(() => { if (open) { setValue(defaultValue); } }, [open, defaultValue]); function submitAction() { startTransition(async () => { try { // toggle the value if it's a boolean otherwise use the value const newValue = typeof value === "boolean" ? !value : value; const promise = checkoutSessionMutation.mutateAsync({ workspaceSlug: workspace.slug, feature: addon, value: newValue, }); toast.promise(promise, { loading: "Updating...", success: () => { setOpen(false); return "Billing information updated"; }, error: (error) => { if (!isTRPCClientError(error)) return { message: "Failed to update" }; if (error.data?.code === "PRECONDITION_FAILED") { return { message: error.message, action: { label: "Add payment method", onClick: () => paymentMethodSetupMutation.mutate({ workspaceSlug: workspace.slug, successUrl: `${window.location.origin}/settings/billing?setup=true`, cancelUrl: `${window.location.origin}/settings/billing`, }), }, }; } return { message: error.message }; }, }); await promise; } catch (error) { console.error(error); } }); } const hasAddon = typeof defaultValue === "number" ? defaultValue > 0 : defaultValue !== defaultLimit; const isQuantity = typeof value === "number"; // Mirrors the server: a boolean submit toggles, so `true` means removing. const isRemoval = typeof value === "boolean" ? value : value === 0; return (
{description}
{price ? `${formatPrice(price)}${getPriceSuffix(isQuantity, packSize, interval)}` : intervalQuery.isPending ? "Loading..." : "N/A"} {hasAddon && !isQuantity ? ( ) : null} {hasAddon && isQuantity && typeof defaultValue === "number" ? ( +{defaultValue * packSize} ) : null}
{label} {getDialogDescription( label, price, value, hasAddon, packSize, unitLabel, interval, )} {isTrialing && !isRemoval ? " Adding it ends your Starter trial and charges your card today." : null} {isQuantity && typeof value === "number" ? ( ) : null} Cancel { e.preventDefault(); submitAction(); }} disabled={ isPending || (typeof value === "number" && typeof defaultValue === "number" && value === defaultValue) } > {getButtonLabel(hasAddon, value, isPending)}
); } // NOTE: could move to lib/formatter.ts function formatAmount(price: PriceConfig | null, amount: number) { if (!price) return "N/A"; return new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(amount); } function formatPrice(price: PriceConfig | null) { if (!price) return "N/A"; return formatAmount(price, price.value); } function getButtonLabel( hasAddon: boolean, value: number | boolean, isPending = false, ) { if (isPending) return "Updating..."; const isBoolean = typeof value === "boolean"; const isQuantity = typeof value === "number"; if (isQuantity) return "Update"; if (isBoolean) { return hasAddon ? "Remove" : "Add"; } return null; } 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( label: string, price: PriceConfig | null, value: number | boolean, 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, interval); if (isBoolean) { if (hasAddon) { return `${label} will be removed from your subscription. You will save ${formattedPrice}${priceSuffix} on your next billing cycle.`; } return `${label} will be added to your subscription. You will be charged an additional ${formattedPrice}${priceSuffix} on your next billing cycle.`; } if (isQuantity) { if (value === 0) { 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}${getPeriodSuffix(interval)}, starting on your next billing cycle.`; } } function QuantityControl({ value, setValue, maxValue, packSize, unitLabel, }: { value: number; setValue: (value: number) => void; maxValue: number | null; packSize: number; unitLabel: string; }) { const clamp = (next: number) => { const floored = Math.max(0, next); return maxValue === null ? floored : Math.min(maxValue, floored); }; const handleChange = (e: React.ChangeEvent) => { const newValue = Number.parseInt(e.target.value); setValue(Number.isNaN(newValue) ? 0 : clamp(newValue)); }; const atMax = maxValue !== null && value >= maxValue; return (
{atMax && maxValue !== null ? (

Need more than {maxValue * packSize} extra {unitLabel}?{" "} Book a call

) : null}
); }