diff --git a/apps/web/src/lib/api/client.test.ts b/apps/web/src/lib/api/client.test.ts index 02bfb602..552acb81 100644 --- a/apps/web/src/lib/api/client.test.ts +++ b/apps/web/src/lib/api/client.test.ts @@ -149,46 +149,73 @@ describe("fetchWithAuth refresh handling", () => { }); describe("payment-required redirect", () => { - test("bills the workspace the request was sent for", async () => { - // Switching workspaces can change the active workspace while a request is - // in flight. Its 402 belongs to the workspace in its X-Company-ID header. - const replaced: string[] = []; - const originalWindow = (globalThis as { window?: unknown }).window; - const originalBillingUrl = process.env.VITE_BILLING_URL; + const originalWindow = (globalThis as { window?: unknown }).window; + const originalBillingUrl = process.env.VITE_BILLING_URL; + let replaced: string[] = []; + let respond: (response: Response) => void = () => undefined; + let sentCompanyId = null as string | null; + + function stubBrowser(pathname: string): void { + replaced = []; (globalThis as { window?: unknown }).window = { location: { origin: "https://app.example.com", + pathname, replace: (url: string) => replaced.push(url), }, }; process.env.VITE_BILLING_URL = "/billing"; - - let respond: (response: Response) => void = () => undefined; - let sentCompanyId = null as string | null; globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { sentCompanyId = (init?.headers as Record)["X-Company-ID"]; return new Promise((resolve) => { respond = resolve; }); }) as unknown as typeof fetch; + } + + async function answerWith402(request: Promise): Promise { + respond( + Response.json({ error: "SUBSCRIPTION_RESTRICTED" }, { status: 402 }), + ); + await expect(request).rejects.toThrow(); + } + + afterEach(() => { + (globalThis as { window?: unknown }).window = originalWindow; + if (originalBillingUrl === undefined) delete process.env.VITE_BILLING_URL; + else process.env.VITE_BILLING_URL = originalBillingUrl; + }); + + test("bills the workspace the request was sent for", async () => { + stubBrowser("/w/new-workspace/chat"); + setCompanyId("new-workspace"); + await answerWith402(fetchWithAuth("/companies")); + + expect(sentCompanyId).toBe("new-workspace"); + expect(replaced).toEqual([ + "/billing?companyId=new-workspace&mode=onboarding", + ]); + }); + + test("ignores a 402 for a workspace the user has left", async () => { + // Switching away from a workspace that needs payment must not be undone by + // a request that was still in flight for it. + stubBrowser("/w/paid-workspace/chat"); + setCompanyId("unpaid-workspace"); + const request = fetchWithAuth("/notifications/count"); + setCompanyId("paid-workspace"); + await answerWith402(request); + + expect(sentCompanyId).toBe("unpaid-workspace"); + expect(replaced).toEqual([]); + }); + + test("never redirects away from the workspace chooser", async () => { + // The chooser is how a user leaves a workspace that needs payment. + stubBrowser("/workspaces"); + setCompanyId("unpaid-workspace"); + await answerWith402(fetchWithAuth("/notifications/count")); - try { - setCompanyId("new-workspace"); - const request = fetchWithAuth("/companies"); - setCompanyId("previous-workspace"); - respond( - Response.json({ error: "SUBSCRIPTION_RESTRICTED" }, { status: 402 }), - ); - - await expect(request).rejects.toThrow(); - expect(sentCompanyId).toBe("new-workspace"); - expect(replaced).toEqual([ - "/billing?companyId=new-workspace&mode=onboarding", - ]); - } finally { - (globalThis as { window?: unknown }).window = originalWindow; - if (originalBillingUrl === undefined) delete process.env.VITE_BILLING_URL; - else process.env.VITE_BILLING_URL = originalBillingUrl; - } + expect(replaced).toEqual([]); }); }); diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 74bc7540..12c642a5 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -4,6 +4,7 @@ */ import { getWorkspaceBillingUrl } from "../billing-url.js"; +import { isWorkspaceChooserPath } from "../workspace-routes.js"; import type { RefreshResponse } from "./types.js"; // API Configuration @@ -30,6 +31,14 @@ function redirectToBillingOnPaymentRequired( ) { return; } + // A 402 for a workspace the user has since left is stale: following it + // would pull them back into the workspace they just switched away from. + // This relies on WorkspaceRouteGuard not reverting a switch in flight; + // otherwise a new workspace's 402 could arrive after a revert and be dropped. + if (workspaceId !== companyId) return; + // The chooser is the way out of a workspace that needs payment, so it must + // never redirect back into that workspace's billing page. + if (isWorkspaceChooserPath(window.location.pathname)) return; const billingUrl = getWorkspaceBillingUrl(workspaceId, { onboarding: true }); if (!billingUrl) return; diff --git a/apps/web/src/lib/workspace-routes.test.ts b/apps/web/src/lib/workspace-routes.test.ts index da213fef..d0d1127b 100644 --- a/apps/web/src/lib/workspace-routes.test.ts +++ b/apps/web/src/lib/workspace-routes.test.ts @@ -3,6 +3,7 @@ import type { MemberPermissions } from "@wateaminbox/shared"; import { chatViewPath, getWorkspaceDestination, + isWorkspaceChooserPath, parseChatView, resolveInitialWorkspaceId, resolveWorkspaceDestination, @@ -139,3 +140,12 @@ describe("chat view query param", () => { }); }); }); + +describe("isWorkspaceChooserPath", () => { + test("matches the chooser with or without a trailing slash", () => { + expect(isWorkspaceChooserPath("/workspaces")).toBe(true); + expect(isWorkspaceChooserPath("/workspaces/")).toBe(true); + expect(isWorkspaceChooserPath("/w/abc/chat")).toBe(false); + expect(isWorkspaceChooserPath("/workspaces-old")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/workspace-routes.ts b/apps/web/src/lib/workspace-routes.ts index 1b8fb6ea..bd47a56c 100644 --- a/apps/web/src/lib/workspace-routes.ts +++ b/apps/web/src/lib/workspace-routes.ts @@ -1,5 +1,12 @@ import type { MemberPermissions } from "@wateaminbox/shared"; +/** Account-level page for choosing a workspace; it belongs to no workspace. */ +export const WORKSPACE_CHOOSER_PATH = "/workspaces"; + +export function isWorkspaceChooserPath(pathname: string): boolean { + return pathname.replace(/\/+$/, "") === WORKSPACE_CHOOSER_PATH; +} + export function resolveInitialWorkspaceId( availableWorkspaceIds: string[], routeWorkspaceId: string | null,