From 8c97ee9ad0e58811d2e341aa585ed133f9a800c5 Mon Sep 17 00:00:00 2001 From: setkyar Date: Mon, 5 Oct 2026 22:27:48 +0700 Subject: [PATCH 1/2] fix(web): send a payment-required redirect to the workspace that was billed The 402 redirect read the active workspace when the response arrived. During a switch to a new, unpaid workspace the active workspace could already have reverted to the previous one, so the user landed on the previous workspace's billing page and could not pay for the new one. Redirect to the workspace named in the request's X-Company-ID header instead. --- apps/web/src/lib/api/client.test.ts | 45 +++++++++++++++++++++++++++++ apps/web/src/lib/api/client.ts | 41 ++++++++++++++++---------- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/api/client.test.ts b/apps/web/src/lib/api/client.test.ts index 4dd49a97..02bfb602 100644 --- a/apps/web/src/lib/api/client.test.ts +++ b/apps/web/src/lib/api/client.test.ts @@ -147,3 +147,48 @@ describe("fetchWithAuth refresh handling", () => { expect(getAccessToken()).toBe("expired"); }); }); + +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; + (globalThis as { window?: unknown }).window = { + location: { + origin: "https://app.example.com", + 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; + + 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; + } + }); +}); diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 3f4e01d9..74bc7540 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -18,17 +18,20 @@ let paymentRedirectStarted = false; const COMPANY_ID_STORAGE_KEY = "company_id"; -function redirectToBillingOnPaymentRequired(status: number): void { +function redirectToBillingOnPaymentRequired( + status: number, + workspaceId: string | null = companyId, +): void { if ( status !== 402 || paymentRedirectStarted || - !companyId || + !workspaceId || typeof window === "undefined" ) { return; } - const billingUrl = getWorkspaceBillingUrl(companyId, { onboarding: true }); + const billingUrl = getWorkspaceBillingUrl(workspaceId, { onboarding: true }); if (!billingUrl) return; paymentRedirectStarted = true; @@ -141,12 +144,17 @@ function sessionRefreshUnavailableError(): ApiRequestError { } // Response handler -export async function handleResponse(response: Response): Promise { +export async function handleResponse( + response: Response, + requestCompanyId: string | null = companyId, +): Promise { if (!response.ok) { // A private deployment may require billing before workspace APIs become // available. Keep the OSS client commercial-logic-free: HTTP 402 plus the // generic configured billing URL is the complete redirect contract. - redirectToBillingOnPaymentRequired(response.status); + // Bill the workspace the request was sent for: the active workspace can + // change while it is in flight, e.g. when switching to a new workspace. + redirectToBillingOnPaymentRequired(response.status, requestCompanyId); let errorData: { code: string; message: string; @@ -277,8 +285,9 @@ export async function fetchWithAuth( } // Add company ID header for multi-tenant support - if (companyId) { - (headers as Record)["X-Company-ID"] = companyId; + const requestCompanyId = companyId; + if (requestCompanyId) { + (headers as Record)["X-Company-ID"] = requestCompanyId; } const response = await fetch(url, { @@ -299,14 +308,14 @@ export async function fetchWithAuth( headers, credentials: "include", }); - return handleResponse(retryResponse); + return handleResponse(retryResponse, requestCompanyId); } // Report the outage rather than the 401 that triggered it, so a caller // does not read a deployment blip as a failed sign-in. if (outcome === "unavailable") throw sessionRefreshUnavailableError(); } - return handleResponse(response); + return handleResponse(response, requestCompanyId); } /** Fetch an authenticated binary response, including token-refresh retry. */ @@ -319,7 +328,8 @@ export async function fetchBlobWithAuth( ...(options.headers as Record | undefined), }; if (accessToken) headers.Authorization = `Bearer ${accessToken}`; - if (companyId) headers["X-Company-ID"] = companyId; + const requestCompanyId = companyId; + if (requestCompanyId) headers["X-Company-ID"] = requestCompanyId; let response = await fetch(url, { ...options, @@ -339,7 +349,7 @@ export async function fetchBlobWithAuth( throw sessionRefreshUnavailableError(); } } - if (!response.ok) await handleResponse(response); + if (!response.ok) await handleResponse(response, requestCompanyId); return response.blob(); } @@ -358,8 +368,9 @@ export async function fetchFormDataWithAuth( headers.Authorization = `Bearer ${accessToken}`; } - if (companyId) { - headers["X-Company-ID"] = companyId; + const requestCompanyId = companyId; + if (requestCompanyId) { + headers["X-Company-ID"] = requestCompanyId; } const response = await fetch(url, { @@ -380,12 +391,12 @@ export async function fetchFormDataWithAuth( body: formData, credentials: "include", }); - return handleResponse(retryResponse); + return handleResponse(retryResponse, requestCompanyId); } if (outcome === "unavailable") throw sessionRefreshUnavailableError(); } - return handleResponse(response); + return handleResponse(response, requestCompanyId); } // Build query string from params From d60f891b7dd81f52f862f47b20782acb7c46972d Mon Sep 17 00:00:00 2001 From: setkyar Date: Mon, 5 Oct 2026 22:27:48 +0700 Subject: [PATCH 2/2] fix(web): keep the route guard from undoing a workspace switch While a switch is in flight the URL still names the previous workspace, and the guard switched straight back to it. Wait for the switch to finish before reconciling the route with the active workspace. --- apps/web/src/components/auth/WorkspaceRouteGuard.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/auth/WorkspaceRouteGuard.tsx b/apps/web/src/components/auth/WorkspaceRouteGuard.tsx index 90852cab..b36134ce 100644 --- a/apps/web/src/components/auth/WorkspaceRouteGuard.tsx +++ b/apps/web/src/components/auth/WorkspaceRouteGuard.tsx @@ -21,8 +21,11 @@ export function WorkspaceRouteGuard() { ); React.useEffect(() => { + // While a switch is in flight the route still names the previous + // workspace. Following it here would undo the switch midway. if ( isLoading || + isSwitching || !workspaceId || !hasMembership || activeWorkspaceId === workspaceId @@ -35,6 +38,7 @@ export function WorkspaceRouteGuard() { activeWorkspaceId, hasMembership, isLoading, + isSwitching, switchWorkspace, workspaceId, ]);