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,
+ )}
+
+ )}
+