From 548b078009cfaa7e163e237e7f714e3544e454f7 Mon Sep 17 00:00:00 2001 From: jiashuoz Date: Sun, 28 Jun 2026 12:00:38 -0700 Subject: [PATCH 1/2] feat(web/billing): render all tiers + quotas from the sidecar catalog (SSOT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The billing page hardcoded a 2-tier catalog (Pro + Scale only, Free omitted) that could drift from plans.go. Replace it with a per-tier quota comparison sourced from GET /api/billing/plan — the billing sidecar's single source of truth — so the dashboard can never lag the caps the webhook actually provisions. - Fetch the plan catalog (gated on NEXT_PUBLIC_BILLING_API; self-host with no sidecar skips it and shows usage only). - Render every tier (Free/Pro/Scale) with agents/domains/messages/ storage + price; highlight the current tier with a badge. - CTAs: free users upgrade via Checkout; active subscribers switch or downgrade via the Stripe Billing Portal. - Degrade gracefully when the catalog fetch fails (retry notice, usage still renders). - Drop the hardcoded PLAN_CATALOG and its drift-warning comment. Tests: new page.catalog.test.tsx covers catalog render, current-tier marking, upgrade→Checkout (with plan body), switch/downgrade→portal, and catalog-fetch degradation; existing page.test.tsx extended to assert the Plans section stays hidden on self-host. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/(app)/billing/page.catalog.test.tsx | 238 +++++++++++++ web/src/app/(app)/billing/page.test.tsx | 5 + web/src/app/(app)/billing/page.tsx | 332 ++++++++++++++---- 3 files changed, 504 insertions(+), 71 deletions(-) create mode 100644 web/src/app/(app)/billing/page.catalog.test.tsx diff --git a/web/src/app/(app)/billing/page.catalog.test.tsx b/web/src/app/(app)/billing/page.catalog.test.tsx new file mode 100644 index 00000000..0bc30c62 --- /dev/null +++ b/web/src/app/(app)/billing/page.catalog.test.tsx @@ -0,0 +1,238 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SWRConfig } from "swr"; + +// Mock next/link so PageShell / Topbar links don't resolve router state. +jest.mock("next/link", () => { + return function MockLink({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + [k: string]: unknown; + }) { + return ( + + {children} + + ); + }; +}); + +// BILLING_API is captured at module-evaluation time from this env var +// (and inlined by Next in prod). Set it BEFORE requiring the page so the +// module sees a configured sidecar — hence `require` here rather than a +// top-level `import`, which would be hoisted above this assignment. +// This is a separate test file from page.test.tsx precisely so the two +// don't fight over the value (Jest gives each test file its own env). +process.env.NEXT_PUBLIC_BILLING_API = "https://billing.test"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const BillingPage = require("./page").default as React.ComponentType; + +const PLAN_URL = "https://billing.test/api/billing/plan"; +const CHECKOUT_URL = "https://billing.test/api/billing/checkout"; +const PORTAL_URL = "https://billing.test/api/billing/portal"; + +// The catalog the sidecar's plans package advertises (Free/Pro/Scale). +const CATALOG = [ + { + code: "free", + display_name: "Free", + monthly_price_cents: 0, + max_agents: 3, + max_domains: 1, + max_messages_month: 3000, + max_storage_bytes: 1 << 30, + }, + { + code: "pro", + display_name: "Pro", + monthly_price_cents: 2000, + max_agents: 25, + max_domains: 10, + max_messages_month: 50000, + max_storage_bytes: 10 * (1 << 30), + }, + { + code: "scale", + display_name: "Scale", + monthly_price_cents: 9900, + max_agents: 250, + max_domains: 50, + max_messages_month: 500000, + max_storage_bytes: 100 * (1 << 30), + }, +]; + +const FREE_LIMITS = { + plan_code: "free", + limits: { + max_agents: 3, + max_domains: 1, + max_messages_month: 3000, + max_storage_bytes: 1 << 30, + }, + usage: { agents: 1, domains: 0, messages_month: 120, storage_bytes: 1024 }, + upgrade_url: "", +}; + +const PRO_LIMITS = { + plan_code: "pro", + limits: { + max_agents: 25, + max_domains: 10, + max_messages_month: 50000, + max_storage_bytes: 10 * (1 << 30), + }, + usage: { agents: 4, domains: 2, messages_month: 9000, storage_bytes: 2048 }, + // upgrade_url present == active subscription; it is also the portal POST target. + upgrade_url: PORTAL_URL, +}; + +const mockFetch = jest.fn(); +beforeEach(() => { + mockFetch.mockReset(); + global.fetch = mockFetch; +}); + +// jsdom's window.location is non-configurable and doesn't implement +// navigation, so we can't observe the final `window.location.href = url` +// redirect directly. The meaningful behavior is which billing endpoint +// gets POSTed (and with what body) — that's what the tests assert. The +// page's redirect assignment is a no-op in jsdom (logged, not thrown), +// so postBilling still completes its happy path. Mock alert so an +// unexpected error path doesn't surface a jsdom dialog. +beforeAll(() => { + window.alert = jest.fn(); +}); + +type StageOpts = { + limits: unknown; + plan?: unknown; // catalog/current payload; omit to 500 the plan fetch + planFails?: boolean; + checkoutUrl?: string; + portalUrl?: string; +}; + +function stage(opts: StageOpts) { + mockFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === "/v1/account") { + return Promise.resolve({ ok: true, json: () => Promise.resolve(opts.limits) }); + } + if (url === PLAN_URL) { + if (opts.planFails) { + return Promise.resolve({ + ok: false, + status: 500, + text: () => Promise.resolve("boom"), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve(opts.plan) }); + } + if (url === CHECKOUT_URL && init?.method === "POST") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ url: opts.checkoutUrl ?? "https://stripe.test/checkout" }), + }); + } + if (url === PORTAL_URL && init?.method === "POST") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ url: opts.portalUrl ?? "https://stripe.test/portal" }), + }); + } + return Promise.resolve({ ok: false, text: () => Promise.resolve("404") }); + }); +} + +function renderPage() { + return render( + new Map(), dedupingInterval: 0 }}> + + , + ); +} + +describe("BillingPage — tier comparison", () => { + it("renders every catalog tier with its quota and prices", async () => { + stage({ limits: FREE_LIMITS, plan: { catalog: CATALOG, current: { code: "free", status: "inactive", has_stripe_customer: false } } }); + renderPage(); + + await waitFor(() => expect(screen.getByText("Plans")).toBeInTheDocument()); + // All three tiers present. "Free" also appears in the current-plan + // banner for this user, so it legitimately matches twice. + expect(screen.getAllByText("Free").length).toBeGreaterThan(0); + expect(screen.getByText("Pro")).toBeInTheDocument(); + expect(screen.getByText("Scale")).toBeInTheDocument(); + // Prices from cents. + expect(screen.getByText("$20/mo")).toBeInTheDocument(); + expect(screen.getByText("$99/mo")).toBeInTheDocument(); + // Quota numbers from the catalog (Scale's caps). + expect(screen.getByText("250")).toBeInTheDocument(); + expect(screen.getByText("500,000")).toBeInTheDocument(); + expect(screen.getByText("100.00 GB")).toBeInTheDocument(); + }); + + it("marks the current tier and offers Upgrade CTAs to a free user", async () => { + stage({ limits: FREE_LIMITS, plan: { catalog: CATALOG, current: { code: "free", status: "inactive", has_stripe_customer: false } } }); + renderPage(); + + await waitFor(() => expect(screen.getByText("Current")).toBeInTheDocument()); + // Free is current → upgrade CTAs only for the paid tiers. + expect(screen.getByRole("button", { name: "Upgrade to Pro" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Upgrade to Scale" })).toBeInTheDocument(); + // Free tier shows no action button. + expect(screen.queryByRole("button", { name: /Upgrade to Free|Downgrade|Switch to Free/ })).not.toBeInTheDocument(); + }); + + it("upgrade routes a free user to Checkout with the chosen plan", async () => { + stage({ limits: FREE_LIMITS, plan: { catalog: CATALOG, current: { code: "free", status: "inactive", has_stripe_customer: false } } }); + renderPage(); + + const btn = await screen.findByRole("button", { name: "Upgrade to Scale" }); + await userEvent.click(btn); + + // Routes to Checkout (not portal) carrying the selected plan code. + await waitFor(() => + expect(mockFetch.mock.calls.some(([u]) => u === CHECKOUT_URL)).toBe(true), + ); + const call = mockFetch.mock.calls.find(([u]) => u === CHECKOUT_URL); + expect(call![1].method).toBe("POST"); + expect(JSON.parse(call![1].body)).toEqual({ plan: "scale" }); + expect(mockFetch.mock.calls.some(([u]) => u === PORTAL_URL)).toBe(false); + }); + + it("offers Switch/Downgrade via the portal for an active subscriber", async () => { + stage({ limits: PRO_LIMITS, plan: { catalog: CATALOG, current: { code: "pro", status: "active", has_stripe_customer: true } } }); + renderPage(); + + await waitFor(() => expect(screen.getByText("Current")).toBeInTheDocument()); + // Pro is current (no button); Scale = switch up, Free = downgrade. + const switchBtn = screen.getByRole("button", { name: "Switch to Scale" }); + expect(switchBtn).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Downgrade" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Switch to Pro|Upgrade/ })).not.toBeInTheDocument(); + + // A switch routes to the Stripe portal (upgrade_url), not checkout. + await userEvent.click(switchBtn); + await waitFor(() => + expect( + mockFetch.mock.calls.some(([u, i]) => u === PORTAL_URL && i?.method === "POST"), + ).toBe(true), + ); + expect(mockFetch.mock.calls.some(([u]) => u === CHECKOUT_URL)).toBe(false); + }); + + it("degrades gracefully when the plan catalog fails to load", async () => { + stage({ limits: FREE_LIMITS, planFails: true }); + renderPage(); + + // Usage still renders (it comes from /v1/account, a separate fetch). + await waitFor(() => expect(screen.getByText("Inboxes")).toBeInTheDocument()); + // The Plans section shows a retry notice rather than tier cards. + expect(screen.getByText(/Couldn't load plans/i)).toBeInTheDocument(); + expect(screen.queryByText("Scale")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/app/(app)/billing/page.test.tsx b/web/src/app/(app)/billing/page.test.tsx index d07684c7..46599de1 100644 --- a/web/src/app/(app)/billing/page.test.tsx +++ b/web/src/app/(app)/billing/page.test.tsx @@ -100,6 +100,11 @@ describe("BillingPage", () => { await waitFor(() => screen.getByText(/Default/i)); expect(screen.queryByText(/Upgrade/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Manage billing/i)).not.toBeInTheDocument(); + // The plan comparison is also a sidecar-only surface — with no + // BILLING_API there's no catalog to fetch, so the Plans section and + // its tier cards must not render. + expect(screen.queryByText("Plans")).not.toBeInTheDocument(); + expect(screen.queryByText("Scale")).not.toBeInTheDocument(); }); it("renders error state when the API returns non-2xx", async () => { diff --git a/web/src/app/(app)/billing/page.tsx b/web/src/app/(app)/billing/page.tsx index 16a39a7e..406e5ce1 100644 --- a/web/src/app/(app)/billing/page.tsx +++ b/web/src/app/(app)/billing/page.tsx @@ -25,11 +25,40 @@ type LimitsInfo = { upgrade_url: string; }; +// PlanInfo mirrors the response of GET /api/billing/plan on the hosted +// billing sidecar. The sidecar's plans package is the single source of +// truth for what each tier includes and what it costs; we render the +// comparison straight from it so the dashboard can never drift from the +// caps the webhook actually provisions. Stripe price IDs stay +// server-side — the client only ever sends a plan `code`. +type PlanEntry = { + code: string; + display_name: string; + monthly_price_cents: number; + max_agents: number; + max_domains: number; + max_messages_month: number; + max_storage_bytes: number; +}; + +type CurrentState = { + code: string; + status: string; + current_period_end?: string; + has_stripe_customer: boolean; +}; + +type PlanInfo = { + catalog: PlanEntry[]; + current: CurrentState; +}; + // BILLING_API is the base URL of the external limits provisioner (the // hosted billing sidecar). When empty the dashboard renders the usage -// surface without Upgrade / Manage Billing affordances — appropriate -// for self-host deployments that don't run a paid tier. Set at build -// time via NEXT_PUBLIC_BILLING_API; populated in the prod web image. +// surface without the plan comparison or Upgrade / Manage Billing +// affordances — appropriate for self-host deployments that don't run a +// paid tier. Set at build time via NEXT_PUBLIC_BILLING_API; populated in +// the prod web image. const BILLING_API = (process.env.NEXT_PUBLIC_BILLING_API ?? "").replace(/\/$/, ""); async function fetchLimits(): Promise { @@ -41,6 +70,15 @@ async function fetchLimits(): Promise { return res.json(); } +async function fetchPlan(url: string): Promise { + const res = await fetch(url, { credentials: "include" }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`plan: HTTP ${res.status}${body ? ` — ${body}` : ""}`); + } + return res.json(); +} + // formatBytes renders storage in the unit that makes the number // human-legible — KB / MB / GB. Matches the formatting Resend and // AgentMail use in their dashboards so users carrying mental models @@ -56,6 +94,27 @@ function formatNumber(n: number): string { return n.toLocaleString(); } +// formatPrice turns the catalog's integer cents into the dashboard's +// price label. $0 reads "Free"; whole-dollar amounts drop the trailing +// ".00" so "$20/mo" not "$20.00/mo". +function formatPrice(cents: number): string { + if (cents <= 0) return "Free"; + const dollars = cents / 100; + const amount = Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`; + return `${amount}/mo`; +} + +// QUOTA_DIMS is the data-driven list of caps we show per tier. Each +// dimension formats its own cell from a PlanEntry, so adding a future +// cap (webhooks, seats, …) is one entry here plus the matching field on +// PlanEntry — no per-tier markup to touch. +const QUOTA_DIMS: { label: string; format: (p: PlanEntry) => string }[] = [ + { label: "Inboxes", format: (p) => formatNumber(p.max_agents) }, + { label: "Domains", format: (p) => formatNumber(p.max_domains) }, + { label: "Messages / mo", format: (p) => formatNumber(p.max_messages_month) }, + { label: "Storage", format: (p) => formatBytes(p.max_storage_bytes) }, +]; + // pctTone picks the bar color based on how close to the cap the user // is. <70% neutral, 70-90% warning, >=90% danger. Tones reuse existing // CSS vars so the dashboard's theme tokens own the actual colors. @@ -65,17 +124,6 @@ function pctTone(pct: number): "neutral" | "warn" | "danger" { return "neutral"; } -// PLAN_CATALOG mirrors the operator-side plan catalog at -// e2a-ops/billing/internal/plans/plans.go. Hardcoded here because the -// dashboard isn't yet wired to the sidecar's GET /api/billing/plan -// listing endpoint — and even if it were, the values are stable enough -// that an extra round-trip on the upgrade page isn't worth it. If you -// change a cap on either side, update both files in the same PR. -const PLAN_CATALOG = [ - { code: "pro", name: "Pro", price: "$20/mo", chips: ["25 inboxes", "10 domains", "50k msgs/mo", "10 GiB"] }, - { code: "scale", name: "Scale", price: "$99/mo", chips: ["250 inboxes", "50 domains", "500k msgs/mo", "100 GiB"] }, -] as const; - type UsageRowProps = { label: string; current: string; @@ -113,17 +161,99 @@ function UsageRow({ label, current, limit, pct }: UsageRowProps) { ); } +// PlanCTA describes the action button on one tier card. `null` means the +// tier shows no button (e.g. the Free tier for a brand-new user — it's +// already their plan, nothing to buy). +type PlanCTA = { + label: string; + onClick?: () => void; + disabled: boolean; +}; + +function PlanCard({ + tier, + isCurrent, + cta, +}: { + tier: PlanEntry; + isCurrent: boolean; + cta: PlanCTA | null; +}) { + return ( +
+
+
+ + {tier.display_name} + + {isCurrent && ( + + Current + + )} +
+
+ {formatPrice(tier.monthly_price_cents)} +
+
+ +
+ {QUOTA_DIMS.map((d) => ( +
+
{d.label}
+
{d.format(tier)}
+
+ ))} +
+ + {cta && ( + + )} +
+ ); +} + export default function BillingPage() { const { data, error, isLoading, mutate } = useSWR( "limits", fetchLimits, ); - // Track the specific action in flight so each button can show its own - // "Opening…" label while disabling the others. Two upgrade variants - // because the page renders a Pro and a Scale CTA side-by-side. - type PendingAction = "upgrade-pro" | "upgrade-scale" | "portal"; - const [actionPending, setActionPending] = useState(null); + // The plan catalog comes from the sidecar's source of truth. Gated on + // BILLING_API so self-host builds (no sidecar) skip the fetch entirely + // — SWR treats a null key as "don't fetch". + const { + data: planData, + error: planError, + mutate: mutatePlan, + } = useSWR( + BILLING_API ? `${BILLING_API}/api/billing/plan` : null, + fetchPlan, + ); + + // Track which specific button is in flight so it can show "Opening…" + // while the others disable. Tier CTAs are keyed `tier-`; the + // banner's Manage-billing button is "manage". + const [actionPending, setActionPending] = useState(null); // Both Upgrade and Manage Billing POST to the sidecar and follow the // returned `url`. POST (not GET) because the OSS session cookie is @@ -135,7 +265,7 @@ export default function BillingPage() { // // `body` carries the plan selector for /api/billing/checkout (sidecar // defaults to Pro when absent). /api/billing/portal ignores it. - async function postBilling(endpoint: string, kind: PendingAction, body?: unknown) { + async function postBilling(endpoint: string, kind: string, body?: unknown) { setActionPending(kind); try { const res = await fetch(endpoint, { @@ -171,17 +301,74 @@ export default function BillingPage() { // stuck after a Back navigation. useEffect(() => { const onShow = (e: PageTransitionEvent) => { - if (e.persisted) void mutate(); + if (e.persisted) { + void mutate(); + void mutatePlan(); + } }; window.addEventListener("pageshow", onShow); return () => window.removeEventListener("pageshow", onShow); - }, [mutate]); + }, [mutate, mutatePlan]); // Compute usage percentages once data is loaded. Guard zero limits // (treat as 0% rather than NaN/Infinity) so a misconfigured row with // max_*=0 doesn't paint a full red bar. const pct = (used: number, cap: number) => (cap > 0 ? (used / cap) * 100 : 0); + // Current tier: prefer the sidecar's billing truth, fall back to the + // OSS-enforced plan_code so the banner still labels correctly when the + // catalog fetch hasn't landed (or isn't present on self-host). + const currentCode = planData?.current.code ?? data?.plan_code ?? ""; + + // hasSub: the user has an active Stripe subscription. upgrade_url is + // only populated by the OSS server when a subscription exists, and it + // *is* the portal POST target — so it doubles as the "has subscription" + // signal and the endpoint for plan changes / cancellation. + const hasSub = !!data?.upgrade_url; + + function ctaFor(tier: PlanEntry): PlanCTA | null { + // The current tier is marked with a "Current" badge and offers no + // action button — there's nothing to do on the plan you're already on. + if (tier.code === currentCode) { + return null; + } + if (hasSub) { + // Existing subscribers change or cancel their plan through the + // Stripe Billing Portal (it owns proration). Both "switch up/down" + // and "downgrade to Free" route there. + const label = + tier.monthly_price_cents <= 0 ? "Downgrade" : `Switch to ${tier.display_name}`; + const key = `tier-${tier.code}`; + return { + label: actionPending === key ? "Opening…" : label, + onClick: () => postBilling(data!.upgrade_url, key), + disabled: actionPending !== null, + }; + } + // No subscription yet. The Free tier is already their plan (handled + // above as current), so only paid tiers get an Upgrade CTA → Checkout. + if (tier.monthly_price_cents <= 0) { + return null; + } + const key = `tier-${tier.code}`; + return { + label: actionPending === key ? "Opening…" : `Upgrade to ${tier.display_name}`, + onClick: () => + postBilling(`${BILLING_API}/api/billing/checkout`, key, { plan: tier.code }), + disabled: actionPending !== null, + }; + } + + // Human label for the current plan in the banner. "default" is the + // operator-configured self-host plan (not a catalog tier); otherwise + // prefer the catalog's display name, falling back to the raw code. + const currentPlanLabel = + data?.plan_code === "default" + ? "Default (operator-configured)" + : planData?.catalog.find((p) => p.code === currentCode)?.display_name ?? + data?.plan_code ?? + ""; + return (
- {data.plan_code === "default" - ? "Default (operator-configured)" - : data.plan_code} + {currentPlanLabel}
{BILLING_API && data.upgrade_url && ( // upgrade_url present → user has an active Stripe // subscription. Clicking POSTs to the sidecar, which // returns a fresh Stripe Billing Portal URL. From the - // Portal, users can switch plans (Pro ↔ Scale) and - // cancel — that's why we don't render separate - // upgrade-to-Scale buttons for paid users. + // Portal, users switch plans (Pro ↔ Scale) and cancel.
)} + - {/* Plan picker for free users: render Pro + Scale side-by-side - with the caps each tier includes spelled out, so users - aren't picking blind from "Upgrade to Pro · $20/mo" alone. - Hidden once the user has an active subscription — they - manage plan changes through the Stripe Billing Portal. */} - {BILLING_API && !data.upgrade_url && ( -
- {PLAN_CATALOG.map((p) => { - const pendingKey = `upgrade-${p.code}` as PendingAction; - return ( -
-
-
{p.name}
-
{p.price}
-
-
    - {p.chips.map((c) => ( -
  • · {c}
  • - ))} -
- -
- ); - })} + {/* Plan comparison: every tier and its quota, sourced from the + sidecar catalog (the SSOT). Hidden on self-host (no + BILLING_API). The current tier is highlighted; each tier + carries the right CTA for the user's subscription state. */} + {BILLING_API && ( +
+
+
+ Plans +
+
+ Compare what each tier includes. +
- )} -
+ + {planError && ( +
+ Couldn't load plans.{" "} + +
+ )} + + {!planError && !planData && ( +
Loading plans…
+ )} + + {planData && ( +
+ {planData.catalog.map((tier) => ( + + ))} +
+ )} + + )} {/* Usage card */}
Date: Sun, 28 Jun 2026 12:08:00 -0700 Subject: [PATCH 2/2] fix(web/billing): tighten CTA type + fail safe on unknown current plan Review follow-ups: - PlanCTA.onClick is now required (every non-null CTA always supplies one); drop the now-dead `|| !cta.onClick` button guard. - ctaFor: when the current plan can't be determined (both sidecar current.code and OSS plan_code empty), render no plan-change buttons rather than risk offering Checkout for a plan the user may hold. Adds a regression test for the fail-safe. - Document that max_storage_bytes is an int64 server-side but safe as a JS number (largest cap ~100 GiB << 2^53). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/(app)/billing/page.catalog.test.tsx | 18 ++++++++++++++++++ web/src/app/(app)/billing/page.tsx | 13 +++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/web/src/app/(app)/billing/page.catalog.test.tsx b/web/src/app/(app)/billing/page.catalog.test.tsx index 0bc30c62..6f66ef42 100644 --- a/web/src/app/(app)/billing/page.catalog.test.tsx +++ b/web/src/app/(app)/billing/page.catalog.test.tsx @@ -225,6 +225,24 @@ describe("BillingPage — tier comparison", () => { expect(mockFetch.mock.calls.some(([u]) => u === CHECKOUT_URL)).toBe(false); }); + it("offers no plan-change actions when the current plan can't be determined", async () => { + // Fail-safe path: both the sidecar's current.code and the OSS + // plan_code are empty, so currentCode resolves to "". No tier should + // be marked current and no Upgrade/Switch/Downgrade button should + // render — we don't act on an unknown current plan. + stage({ + limits: { ...FREE_LIMITS, plan_code: "" }, + plan: { catalog: CATALOG, current: { code: "", status: "inactive", has_stripe_customer: false } }, + }); + renderPage(); + + await waitFor(() => expect(screen.getByText("Plans")).toBeInTheDocument()); + expect(screen.queryByText("Current")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Upgrade|Switch|Downgrade/ }), + ).not.toBeInTheDocument(); + }); + it("degrades gracefully when the plan catalog fails to load", async () => { stage({ limits: FREE_LIMITS, planFails: true }); renderPage(); diff --git a/web/src/app/(app)/billing/page.tsx b/web/src/app/(app)/billing/page.tsx index 406e5ce1..20caff48 100644 --- a/web/src/app/(app)/billing/page.tsx +++ b/web/src/app/(app)/billing/page.tsx @@ -38,6 +38,8 @@ type PlanEntry = { max_agents: number; max_domains: number; max_messages_month: number; + // int64 on the Go side; safe as a JS number — the largest tier cap + // (~100 GiB ≈ 1.07e11) is far below Number.MAX_SAFE_INTEGER (2^53). max_storage_bytes: number; }; @@ -166,7 +168,7 @@ function UsageRow({ label, current, limit, pct }: UsageRowProps) { // already their plan, nothing to buy). type PlanCTA = { label: string; - onClick?: () => void; + onClick: () => void; disabled: boolean; }; @@ -221,7 +223,7 @@ function PlanCard({ {cta && (