diff --git a/web/src/app/(app)/billing/page.addon.test.tsx b/web/src/app/(app)/billing/page.addon.test.tsx new file mode 100644 index 000000000..445784a20 --- /dev/null +++ b/web/src/app/(app)/billing/page.addon.test.tsx @@ -0,0 +1,405 @@ +import { act, render, screen, waitFor, fireEvent } 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. +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 ADDON_URL = "https://billing.test/api/billing/addon"; +const PORTAL_URL = "https://billing.test/api/billing/portal"; + +const CATALOG = [ + { + code: "free", + display_name: "Free", + monthly_price_cents: 0, + max_agents: 3, + max_domains: 3, + 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), + }, +]; + +// Mirrors the sidecar's AddOnEntry (plans.InboxAddOn): $2.00/mo per +// unit, each unit adds 1 inbox + 3,000 sends/mo, any quantity lifts the +// Free daily cap. max_quantity is deliberately SMALL so the ceiling is +// reachable in one or two clicks (prod's is 1000). +const ADDON = { + code: "addon_inbox", + display_name: "Inbox add-on", + monthly_price_cents_per_unit: 200, + max_quantity: 3, + per_unit: { max_agents: 1, max_messages_month: 3000 }, + lifts_free_daily_cap: true, +}; + +const FREE_LIMITS = { + plan_code: "free", + limits: { + max_agents: 3, + max_domains: 3, + 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, +}; + +function proPlan(addonQuantity: number) { + return { + catalog: CATALOG, + addon: ADDON, + current: { + code: "pro", + status: "active", + has_stripe_customer: true, + addon_quantity: addonQuantity, + }, + }; +} + +const mockFetch = jest.fn(); +// The plan payload is a mutable `let` so sync tests can flip it +// mid-flight and watch the page's polling pick the change up — the +// same pattern page.refresh.test.tsx uses. +let planPayload: unknown; +let limitsPayload: unknown; +let addonResponse: { url?: string; updated?: boolean }; +let addonFails: { status: number; body: string } | null; + +beforeEach(() => { + mockFetch.mockReset(); + addonResponse = { url: "https://stripe.test/checkout" }; + addonFails = null; + mockFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === "/v1/account") { + return Promise.resolve({ ok: true, json: () => Promise.resolve(limitsPayload) }); + } + if (url === PLAN_URL) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(planPayload) }); + } + if (url === ADDON_URL && init?.method === "POST") { + const failure = addonFails; + if (failure) { + return Promise.resolve({ + ok: false, + status: failure.status, + text: () => Promise.resolve(failure.body), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve(addonResponse) }); + } + return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve("404") }); + }); + global.fetch = mockFetch; +}); + +beforeAll(() => { + window.alert = jest.fn(); + // In-place increases ask for confirmation before charging; default to + // accepting so most tests exercise the post path. The decline test + // overrides per-call. + window.confirm = jest.fn(() => true); +}); + +beforeEach(() => { + (window.alert as jest.Mock).mockClear(); + (window.confirm as jest.Mock).mockClear(); + (window.confirm as jest.Mock).mockImplementation(() => true); +}); + +function renderPage() { + return render( + new Map(), dedupingInterval: 0 }}> + + , + ); +} + +function addonPosts(): { quantity: number }[] { + return mockFetch.mock.calls + .filter(([u, init]: [string, RequestInit?]) => u === ADDON_URL && init?.method === "POST") + .map(([, init]: [string, RequestInit]) => JSON.parse(init.body as string)); +} + +describe("BillingPage — inbox add-on", () => { + it("renders the add-on card from the catalog payload", async () => { + limitsPayload = FREE_LIMITS; + planPayload = { + catalog: CATALOG, + addon: ADDON, + current: { code: "free", status: "inactive", has_stripe_customer: false, addon_quantity: 0 }, + }; + renderPage(); + + await waitFor(() => expect(screen.getByText("Inbox add-on")).toBeInTheDocument()); + // Price and per-unit adds come from the payload, never hardcoded. + expect(screen.getByText(/\$2\/mo each/)).toBeInTheDocument(); + expect(screen.getByText(/adds 1 inbox and 3,000 sends/)).toBeInTheDocument(); + // Free plan + lifts_free_daily_cap → the daily-cap hint shows. + expect(screen.getByText(/daily send cap/)).toBeInTheDocument(); + }); + + it("omits the daily-cap hint on a paid plan", async () => { + limitsPayload = PRO_LIMITS; + planPayload = proPlan(0); + renderPage(); + + await screen.findByText("Inbox add-on"); + expect(screen.queryByText(/daily send cap/)).not.toBeInTheDocument(); + }); + + it("hides the card when the sidecar payload has no addon entry", async () => { + limitsPayload = FREE_LIMITS; + planPayload = { + catalog: CATALOG, + current: { code: "free", status: "inactive", has_stripe_customer: false }, + }; + renderPage(); + + await waitFor(() => expect(screen.getByText("Plans")).toBeInTheDocument()); + expect(screen.queryByText("Inbox add-on")).not.toBeInTheDocument(); + }); + + it("buys add-ons via Checkout for a user with no subscription", async () => { + limitsPayload = FREE_LIMITS; + planPayload = { + catalog: CATALOG, + addon: ADDON, + current: { code: "free", status: "inactive", has_stripe_customer: false, addon_quantity: 0 }, + }; + renderPage(); + + await screen.findByText("Inbox add-on"); + // Step 0 → 2, then buy. The proposed total shows before commit. + const inc = screen.getByRole("button", { name: "Increase add-on quantity" }); + await userEvent.click(inc); + await userEvent.click(inc); + expect(screen.getByText(/New total:/)).toHaveTextContent("$4/mo"); + await userEvent.click(screen.getByRole("button", { name: "Buy add-ons" })); + + await waitFor(() => expect(addonPosts()).toEqual([{ quantity: 2 }])); + // Took the redirect branch: no in-place provisioning notice, no + // confirm dialog (Stripe's page shows the price), no error. + expect(screen.queryByText(/Updating your add-ons/)).not.toBeInTheDocument(); + expect(window.confirm).not.toHaveBeenCalled(); + expect(window.alert).not.toHaveBeenCalled(); + }); + + it("updates quantity in place for a subscriber after confirmation", async () => { + limitsPayload = PRO_LIMITS; + planPayload = proPlan(1); + addonResponse = { updated: true }; + renderPage(); + + await screen.findByText("Inbox add-on"); + // Current quantity from the payload. + expect(screen.getByLabelText("Add-on quantity")).toHaveValue(1); + + await userEvent.click(screen.getByRole("button", { name: "Increase add-on quantity" })); + await userEvent.click(screen.getByRole("button", { name: "Update add-ons" })); + + // An in-place increase charges immediately → the confirm carries the + // new total. + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("$4/mo")); + await waitFor(() => expect(addonPosts()).toEqual([{ quantity: 2 }])); + // No redirect; the page reports the webhook is provisioning and the + // action is locked for the whole pending window — a live button here + // is how duplicate charges happen. + await waitFor(() => + expect(screen.getByText(/Updating your add-ons/)).toBeInTheDocument(), + ); + expect(screen.getByRole("button", { name: "Updating…" })).toBeDisabled(); + }); + + it("declining the confirmation sends nothing", async () => { + limitsPayload = PRO_LIMITS; + planPayload = proPlan(1); + (window.confirm as jest.Mock).mockImplementation(() => false); + renderPage(); + + await screen.findByText("Inbox add-on"); + await userEvent.click(screen.getByRole("button", { name: "Increase add-on quantity" })); + await userEvent.click(screen.getByRole("button", { name: "Update add-ons" })); + + expect(window.confirm).toHaveBeenCalled(); + expect(addonPosts()).toEqual([]); + // Declining leaves the page interactive. + expect(screen.getByRole("button", { name: "Update add-ons" })).not.toBeDisabled(); + }); + + it("disables the action when the desired quantity equals the current one", async () => { + limitsPayload = PRO_LIMITS; + planPayload = proPlan(3); + renderPage(); + + await screen.findByText("Inbox add-on"); + expect(screen.getByRole("button", { name: "Update add-ons" })).toBeDisabled(); + expect(screen.getByLabelText("Add-on quantity")).toHaveValue(3); + }); + + it("clamps the stepper to the catalog ceiling and the zero floor", async () => { + limitsPayload = PRO_LIMITS; + planPayload = proPlan(2); + renderPage(); + + await screen.findByText("Inbox add-on"); + const inc = screen.getByRole("button", { name: "Increase add-on quantity" }); + const dec = screen.getByRole("button", { name: "Decrease add-on quantity" }); + + // 2 → 3 = max_quantity → + disables. + await userEvent.click(inc); + expect(screen.getByLabelText("Add-on quantity")).toHaveValue(3); + expect(inc).toBeDisabled(); + + // Down to the floor: 3 → 0 → − disables. + await userEvent.click(dec); + await userEvent.click(dec); + await userEvent.click(dec); + expect(screen.getByLabelText("Add-on quantity")).toHaveValue(0); + expect(dec).toBeDisabled(); + + // Typing past the ceiling clamps too. + const input = screen.getByLabelText("Add-on quantity"); + fireEvent.change(input, { target: { value: "999" } }); + expect(input).toHaveValue(3); + }); + + it("does not stage a cancel-everything when the input is cleared mid-retype", async () => { + limitsPayload = PRO_LIMITS; + planPayload = proPlan(2); + renderPage(); + + await screen.findByText("Inbox add-on"); + const input = screen.getByLabelText("Add-on quantity"); + // Backspacing to empty must hold the previous value rather than + // arming a one-click "set to 0". + fireEvent.change(input, { target: { value: "" } }); + expect(input).toHaveValue(2); + expect(screen.getByRole("button", { name: "Update add-ons" })).toBeDisabled(); + expect(addonPosts()).toEqual([]); + }); + + it("surfaces an error and re-enables the button when the POST fails", async () => { + limitsPayload = PRO_LIMITS; + planPayload = proPlan(1); + addonFails = { status: 502, body: "add-on change failed" }; + renderPage(); + + await screen.findByText("Inbox add-on"); + await userEvent.click(screen.getByRole("button", { name: "Decrease add-on quantity" })); + await userEvent.click(screen.getByRole("button", { name: "Update add-ons" })); + + await waitFor(() => expect(window.alert).toHaveBeenCalled()); + // Failure clears the in-flight state so the user can retry. + expect(screen.getByRole("button", { name: "Update add-ons" })).not.toBeDisabled(); + }); +}); + +describe("BillingPage — add-on provisioning sync", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + async function stageAndApply(user: ReturnType) { + await screen.findByText("Inbox add-on"); + await user.click(screen.getByRole("button", { name: "Increase add-on quantity" })); + await user.click(screen.getByRole("button", { name: "Update add-ons" })); + await waitFor(() => + expect(screen.getByText(/Updating your add-ons/)).toBeInTheDocument(), + ); + } + + it("polls until the webhook lands, then unlocks and tracks the server", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + limitsPayload = PRO_LIMITS; + planPayload = proPlan(1); + addonResponse = { updated: true }; + renderPage(); + + await stageAndApply(user); + + // Webhook lands between polls: the stored quantity reaches the target. + planPayload = proPlan(2); + await act(async () => { + await jest.advanceTimersByTimeAsync(2000); + }); + + await waitFor(() => + expect(screen.queryByText(/Updating your add-ons/)).not.toBeInTheDocument(), + ); + // Input tracks the server again and the action re-locks as a no-op. + expect(screen.getByLabelText("Add-on quantity")).toHaveValue(2); + expect(screen.getByRole("button", { name: "Update add-ons" })).toBeDisabled(); + }); + + it("shows the timeout notice when provisioning outlasts the window", async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + limitsPayload = PRO_LIMITS; + planPayload = proPlan(1); + addonResponse = { updated: true }; + renderPage(); + + await stageAndApply(user); + + // The webhook never lands; the deadline passes. + await act(async () => { + await jest.advanceTimersByTimeAsync(21000); + }); + + await waitFor(() => + expect(screen.getByText(/haven't appeared yet|hasn't|keeps checking/)).toBeInTheDocument(), + ); + expect(screen.queryByText(/Updating your add-ons/)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/app/(app)/billing/page.refresh.test.tsx b/web/src/app/(app)/billing/page.refresh.test.tsx index 47533bf9c..a2f1d9044 100644 --- a/web/src/app/(app)/billing/page.refresh.test.tsx +++ b/web/src/app/(app)/billing/page.refresh.test.tsx @@ -159,7 +159,7 @@ describe("BillingPage — post-checkout reconciliation", () => { // the user the upgrade is being finalized rather than silently // showing them the plan they just paid to leave. await waitFor(() => - expect(screen.getByText(/Finalizing your upgrade/i)).toBeInTheDocument(), + expect(screen.getByText(/Finalizing your purchase/i)).toBeInTheDocument(), ); const callsAfterMount = countCallsTo(PLAN_URL); @@ -177,7 +177,7 @@ describe("BillingPage — post-checkout reconciliation", () => { }); await waitFor(() => expect(screen.getByText("Current")).toBeInTheDocument()); - expect(screen.queryByText(/Finalizing your upgrade/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Finalizing your purchase/i)).not.toBeInTheDocument(); // And it stops once resolved — no unbounded polling of the sidecar. const callsAfterResolve = countCallsTo(PLAN_URL); @@ -206,7 +206,7 @@ describe("BillingPage — post-checkout reconciliation", () => { renderPage(); await waitFor(() => - expect(screen.getByText(/Finalizing your upgrade/i)).toBeInTheDocument(), + expect(screen.getByText(/Finalizing your purchase/i)).toBeInTheDocument(), ); // Just inside the window, after many poll ticks and the re-renders @@ -214,7 +214,7 @@ describe("BillingPage — post-checkout reconciliation", () => { await act(async () => { await jest.advanceTimersByTimeAsync(19000); }); - expect(screen.getByText(/Finalizing your upgrade/i)).toBeInTheDocument(); + expect(screen.getByText(/Finalizing your purchase/i)).toBeInTheDocument(); expect(screen.queryByText(/hasn't appeared yet/i)).not.toBeInTheDocument(); // Just past it — given up, on schedule rather than whenever the effect @@ -232,7 +232,7 @@ describe("BillingPage — post-checkout reconciliation", () => { renderPage(); await waitFor(() => - expect(screen.getByText(/Finalizing your upgrade/i)).toBeInTheDocument(), + expect(screen.getByText(/Finalizing your purchase/i)).toBeInTheDocument(), ); // Past the reconcile window with the sidecar still reporting free. @@ -243,7 +243,7 @@ describe("BillingPage — post-checkout reconciliation", () => { await waitFor(() => expect(screen.getByText(/hasn't appeared yet/i)).toBeInTheDocument(), ); - expect(screen.queryByText(/Finalizing your upgrade/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Finalizing your purchase/i)).not.toBeInTheDocument(); // Stopped polling rather than hammering the sidecar forever. const callsAfterGiveUp = countCallsTo(PLAN_URL); @@ -258,7 +258,7 @@ describe("BillingPage — post-checkout reconciliation", () => { await waitFor(() => expect(screen.getByText("Plans")).toBeInTheDocument()); const callsAfterMount = countCallsTo(PLAN_URL); - expect(screen.queryByText(/Finalizing your upgrade/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Finalizing your purchase/i)).not.toBeInTheDocument(); // Well inside the 30s background cadence: nothing extra should fire. await act(async () => { diff --git a/web/src/app/(app)/billing/page.test.tsx b/web/src/app/(app)/billing/page.test.tsx index 887b7fc94..7694764b0 100644 --- a/web/src/app/(app)/billing/page.test.tsx +++ b/web/src/app/(app)/billing/page.test.tsx @@ -112,7 +112,7 @@ describe("BillingPage", () => { // Post-checkout reconciliation waits on the sidecar's plan read to // report an active subscription. With no sidecar there is no such // read, so a stray ?status=success must not park the page on a - // "finalizing your upgrade" notice that can never resolve — a + // "Finalizing your purchase" notice that can never resolve — a // self-host install has no checkout to come back from. window.history.replaceState({}, "", "/billing?status=success"); stageLimits({ @@ -124,7 +124,7 @@ describe("BillingPage", () => { renderPage(); await waitFor(() => screen.getByText(/Default/i)); - expect(screen.queryByText(/Finalizing your upgrade/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Finalizing your purchase/i)).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 1d9c596a6..5fe056c21 100644 --- a/web/src/app/(app)/billing/page.tsx +++ b/web/src/app/(app)/billing/page.tsx @@ -50,10 +50,32 @@ type CurrentState = { status: string; current_period_end?: string; has_stripe_customer: boolean; + // Stored inbox add-on quantity; 0 (or absent, on an older sidecar) + // when the user has none. + addon_quantity?: number; +}; + +// AddOnEntry mirrors the sidecar's inbox add-on descriptor. Like +// PlanEntry, everything the card renders — price, per-unit grants, the +// quantity ceiling — comes from this payload so the dashboard can never +// disagree with what the webhook actually provisions. +type AddOnEntry = { + code: string; + display_name: string; + monthly_price_cents_per_unit: number; + max_quantity: number; + per_unit: { + max_agents: number; + max_messages_month: number; + }; + lifts_free_daily_cap: boolean; }; type PlanInfo = { catalog: PlanEntry[]; + // Absent on sidecars that predate the add-on — the card simply + // doesn't render, same fail-safe posture as the rest of the page. + addon?: AddOnEntry; current: CurrentState; }; @@ -340,6 +362,11 @@ export default function BillingPage() { useEffect(() => { const onShow = (e: PageTransitionEvent) => { if (e.persisted) { + // A bfcache restore also resurrects any pre-navigation + // actionPending (postBilling/applyAddon deliberately leave it + // set through the redirect) — without this reset every billing + // control on the restored page stays disabled forever. + setActionPending(null); void mutate(); void mutatePlan(); } @@ -416,6 +443,137 @@ export default function BillingPage() { } }, [reconcile, planData]); + // ----- Inbox add-on ----------------------------------------------------- + // The user's edited quantity. `null` means "no edit yet" — the input + // tracks the server's stored quantity, so a webhook-driven change + // (another tab, a portal edit) isn't clobbered by a stale local copy. + const [addonDesired, setAddonDesired] = useState(null); + + // The server quantity the current edit started from. When the server + // moves away from it mid-edit (another tab, the Stripe portal), the + // card surfaces a notice instead of letting a stale total silently + // downgrade the newer value — the classic lost-update on a money + // path. A ref is enough: it only needs reading on renders that a + // server change already triggers. + const addonEditBaseRef = useRef(0); + + // In-place quantity updates return {updated:true} and the caps land + // via the webhook a moment later — the same race as post-Checkout + // reconciliation, so this mirrors that machinery: poll fast until the + // stored quantity matches what we asked for, or time out softly. + const [addonSync, setAddonSync] = useState("idle"); + const addonTargetRef = useRef(null); + const addonDeadlineRef = useRef(null); + + useEffect(() => { + if (addonSync !== "pending") { + addonDeadlineRef.current = null; + return; + } + addonDeadlineRef.current ??= Date.now() + RECONCILE_TIMEOUT_MS; + const id = setInterval(() => { + if (Date.now() >= (addonDeadlineRef.current ?? 0)) { + setAddonSync("timeout"); + return; + } + void mutate(); + void mutatePlan(); + }, RECONCILE_INTERVAL_MS); + return () => clearInterval(id); + }, [addonSync, mutate, mutatePlan]); + + // The webhook landed: stored quantity now matches the request. Clear + // the local edit too so the input goes back to tracking the server — + // but only if the user hasn't already staged a NEWER quantity during + // the pending window; that edit is theirs to keep. + useEffect(() => { + if ( + addonSync !== "idle" && + (planData?.current.addon_quantity ?? 0) === addonTargetRef.current + ) { + setAddonSync("idle"); + // The server now sits at the resolved target; rebase any kept + // edit on it so the change we just made isn't reported as + // "changed elsewhere". + addonEditBaseRef.current = addonTargetRef.current ?? addonEditBaseRef.current; + setAddonDesired((d) => (d === addonTargetRef.current ? null : d)); + } + }, [addonSync, planData]); + + // stageAddonQty is the single entry point for edits: the first edit + // snapshots the server quantity it started from (see addonEditBaseRef). + function stageAddonQty(n: number) { + if (addonDesired === null) addonEditBaseRef.current = addonServerQty; + setAddonDesired(n); + } + + // applyAddon sets the account's TOTAL desired quantity (not a delta). + // Response routing mirrors the sidecar: {url} → hosted Checkout (user + // had no subscription; redirect exactly like postBilling), or + // {updated:true} → the existing subscription was mutated in place and + // the webhook will provision the caps shortly. + async function applyAddon(target: number) { + const addon = planData?.addon; + if (!addon) return; + // In-place increases charge the subscription immediately (prorated + // by Stripe) with no hosted page in between — the one money action + // on this page without a Stripe-owned review step. Make the total + // explicit and get a confirmation before committing. Checkout-path + // purchases (and decreases) skip this: Stripe's page shows the + // price, and a reduction never charges. + if (target > addonServerQty && addonInPlace) { + const total = formatPrice(target * addon.monthly_price_cents_per_unit); + const ok = window.confirm( + `Set inbox add-ons to ${target} (${total} total)? The prorated difference is charged to your subscription immediately.`, + ); + if (!ok) return; + } + setActionPending("addon"); + try { + const res = await fetch(`${BILLING_API}/api/billing/addon`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quantity: target }), + }); + if (!res.ok) { + // Translate the sidecar's documented refusals; fall through to + // the raw status for anything unexpected. + if (res.status === 503) { + throw new Error("the add-on isn't available on this deployment"); + } + const text = await res.text().catch(() => ""); + throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ""}`); + } + const json = (await res.json()) as { url?: string; updated?: boolean }; + if (json.url) { + // Keep the pending state through navigation, like postBilling. + window.location.href = json.url; + return; + } + if (json.updated) { + addonTargetRef.current = target; + // Stamp a FRESH deadline here rather than relying on the sync + // effect's `??=`: a second update issued while the first is + // still pending doesn't re-run that effect (the "pending" set + // is a no-op), and inheriting the first update's deadline would + // truncate — or instantly expire — the second one's window. + addonDeadlineRef.current = Date.now() + RECONCILE_TIMEOUT_MS; + setAddonSync("pending"); + setActionPending(null); + void mutate(); + void mutatePlan(); + return; + } + throw new Error("add-on endpoint returned neither url nor updated"); + } catch (err) { + setActionPending(null); + alert( + `Could not update add-ons: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + // 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. @@ -472,15 +630,42 @@ export default function BillingPage() { }; } + // Add-on card state. The stepper shows the server's stored quantity + // until the user edits it; the action button only lights up when the + // desired total differs from what's already provisioned. + const addonServerQty = planData?.current.addon_quantity ?? 0; + const addonQty = addonDesired ?? addonServerQty; + // Statuses whose subscription the sidecar mutates in place ("Update", + // charged immediately) versus routing to Checkout ("Buy", Stripe + // shows the price first). This mirrors the sidecar's + // hasReusableSubscription list EXACTLY — including the pending/ + // incomplete bring-up windows — because the label is the difference + // between "you'll review the price on Stripe" and "you were just + // charged"; the response shape still decides what actually happens. + const addonInPlace = [ + "active", + "trialing", + "past_due", + "pending_subscription_event", + "incomplete", + ].includes(planData?.current.status ?? ""); + // The user's server quantity moved out from under an in-progress edit + // (another tab, the Stripe portal). Surfaced as a notice so applying + // the stale total is an informed act, not a silent downgrade. + const addonEditConflict = + addonDesired !== null && + addonSync === "idle" && + addonServerQty !== addonEditBaseRef.current; + + const currentTierEntry = planData?.catalog.find((p) => p.code === currentCode); + // 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 ?? - ""; + : currentTierEntry?.display_name ?? data?.plan_code ?? ""; return ( - Finalizing your upgrade… your new plan will appear here in a moment. + Finalizing your purchase… your new plan and limits will appear here in a moment. )} {reconcile === "timeout" && planData?.current.status !== "active" && ( @@ -529,7 +714,7 @@ export default function BillingPage() { }} role="status" > - Your payment went through, but the new plan hasn't appeared yet. + Your payment went through, but the change hasn't appeared yet. It usually lands within a minute — this page keeps checking, or use Refresh below. @@ -627,6 +812,199 @@ export default function BillingPage() { )} + {/* Inbox add-on: purchase/adjust the one add-on SKU. Rendered + only when the sidecar advertises it (older sidecars omit + `addon` and the card fail-safes to hidden). Everything shown + — price, per-unit grants, quantity ceiling — comes from the + catalog payload. The POST sends the desired TOTAL quantity; + the sidecar routes it to Checkout or an in-place mutation. */} + {BILLING_API && planData?.addon && ( +
+
+
+ Add-on +
+
+ + {planData.addon.display_name} + + + {formatPrice(planData.addon.monthly_price_cents_per_unit)} each + +
+
+ Each one adds {formatNumber(planData.addon.per_unit.max_agents)}{" "} + inbox{planData.addon.per_unit.max_agents === 1 ? "" : "es"} and{" "} + {formatNumber(planData.addon.per_unit.max_messages_month)} sends + / mo to your account's shared pool. Adjust anytime; charges + are prorated. +
+ {/* The free tier is identified structurally (price 0), + like ctaFor does — never by a hardcoded plan code. */} + {planData.addon.lifts_free_daily_cap && + currentTierEntry && + currentTierEntry.monthly_price_cents <= 0 && ( +
+ Any add-on also removes the Free plan's daily send cap. +
+ )} +
+ + {addonEditConflict && ( +
+ Your add-on quantity changed elsewhere — the account now has{" "} + {formatNumber(addonServerQty)}. Applying will set the total to + the number shown here. +
+ )} + + {addonSync === "pending" && ( +
+ Updating your add-ons… the new caps will appear here in a + moment. +
+ )} + {addonSync === "timeout" && ( +
+ Your add-on change went through, but the new caps haven't + appeared yet. It usually lands within a minute — this page + keeps checking. +
+ )} + +
+
+ + { + const n = Number.parseInt(e.target.value, 10); + // A cleared field mid-retype must NOT stage 0 — + // that would arm a one-click cancel-everything. + // Hold the previous value until a digit arrives. + if (Number.isNaN(n)) return; + stageAddonQty( + Math.min(planData.addon!.max_quantity, Math.max(0, n)), + ); + }} + className="w-16 text-center text-sm py-1.5 bg-transparent border-x outline-none" + style={{ borderColor: "var(--border)", color: "var(--fg)" }} + /> + +
+ + {/* The action is also disabled through the whole `pending` + window: the previous change is still provisioning, and + the button being live then is how duplicate money + POSTs happen. */} + + + {/* Proposed new monthly total, shown the moment the + staged quantity diverges — nobody should commit to a + number they haven't seen. */} + {addonQty !== addonServerQty && ( + + New total:{" "} + {addonQty === 0 + ? "$0/mo" + : formatPrice( + addonQty * planData.addon.monthly_price_cents_per_unit, + )} + + )} + + {addonServerQty > 0 && ( + + Currently {formatNumber(addonServerQty)} — {" "} + {formatPrice( + addonServerQty * planData.addon.monthly_price_cents_per_unit, + )} + + )} +
+
+ )} + {/* Usage card */}