From 22379fd5ab6995a2d755e37d666d0d60d5038e2f Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:28:44 +0100 Subject: [PATCH] fe(payg): show the usage-limit modal when the limit is hit (direct + policy) (#6626) --- .../policy/engine/PolicyEngine.java | 64 +++++++++ .../proprietary/policy/model/PolicyRun.java | 26 ++++ .../policy/model/PolicyRunView.java | 4 + .../policy/engine/PolicyEngineTest.java | 32 +++++ .../payg/entitlement/EntitlementGuard.java | 4 + .../entitlement/EntitlementGuardTest.java | 2 + .../components/policies/policyRunStore.ts | 2 + .../components/policies/usePolicyAutoRun.ts | 57 +++++++- .../proprietary/services/policyPipeline.ts | 29 ++++ .../saas/components/UsageLimitModalHost.tsx | 18 +++ .../shared/FreeLimitReachedModal.tsx | 8 +- .../shared/SpendCapReachedModal.tsx | 8 +- .../services/paygErrorInterceptor.test.ts | 127 +++++++++++++----- .../src/saas/services/paygErrorInterceptor.ts | 108 ++++++++------- 14 files changed, 398 insertions(+), 91 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index e342a25cf..687c6d1ae 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientResponseException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -199,6 +200,26 @@ public class PolicyEngine { e.getMessage()); run.fail(message); taskManager.setError(runId, message); + } catch (RestClientResponseException e) { + // A downstream tool call returned an error status. When it's a structured entitlement + // response (401/402 with a JSON `error` sentinel), surface that code onto the run so + // the + // client can react — e.g. pop the usage-limit modal — instead of only seeing a generic + // failure. We don't interpret the code here (that would couple this module to the saas + // billing layer); we just pass it through for the client to map. Other statuses fall + // through to the generic failure below. + String code = extractDownstreamErrorCode(e); + if (code != null) { + log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code); + String message = "Usage limit reached"; + run.failWithCode(message, code, extractDownstreamSubscribed(e)); + taskManager.setError(runId, message); + } else { + String message = "Policy run failed: " + e.getMessage(); + log.error("Policy run {} failed (downstream HTTP error)", runId, e); + run.fail(message); + taskManager.setError(runId, message); + } } catch (Exception e) { String message = "Policy run failed: " + e.getMessage(); log.error("Policy run {} failed", runId, e); @@ -281,6 +302,49 @@ public class PolicyEngine { e.getEndpointPath(), e.getReadTimeout().toSeconds()); } + /** Matches the {@code "error":"CODE"} field of a small JSON error body. */ + private static final java.util.regex.Pattern ERROR_CODE_FIELD = + java.util.regex.Pattern.compile("\"error\"\\s*:\\s*\"([^\"]+)\""); + + /** + * Pull the {@code error} sentinel out of a downstream 401/402 JSON body — e.g. the saas + * EntitlementGuard's {@code {"error":"PAYG_LIMIT_REACHED",...}}. Regex (not a JSON parse) on + * purpose: the body is a small, server-controlled shape and this keeps the proprietary module + * free of any billing-layer coupling. Returns null for other statuses or an unmatched body, in + * which case the caller treats it as a generic failure. + */ + private static String extractDownstreamErrorCode(RestClientResponseException e) { + int status = e.getStatusCode().value(); + if (status != 401 && status != 402) { + return null; + } + String body = e.getResponseBodyAsString(); + if (body == null || body.isBlank()) { + return null; + } + java.util.regex.Matcher m = ERROR_CODE_FIELD.matcher(body); + return m.find() ? m.group(1) : null; + } + + /** Matches the {@code "subscribed":true|false} field of a small JSON error body. */ + private static final java.util.regex.Pattern SUBSCRIBED_FIELD = + java.util.regex.Pattern.compile("\"subscribed\"\\s*:\\s*(true|false)"); + + /** + * Pull the {@code subscribed} flag out of a downstream 401/402 JSON body (present on the saas + * {@code PAYG_LIMIT_REACHED} response). Null when absent — the client then defaults to the + * free-limit modal. Regex for the same dependency-free reason as {@link + * #extractDownstreamErrorCode}. + */ + private static Boolean extractDownstreamSubscribed(RestClientResponseException e) { + String body = e.getResponseBodyAsString(); + if (body == null || body.isBlank()) { + return null; + } + java.util.regex.Matcher m = SUBSCRIBED_FIELD.matcher(body); + return m.find() ? Boolean.valueOf(m.group(1)) : null; + } + /** * MDC key {@code UserService.getCurrentUsername()} reads as its async fallback (stamped by the * controller audit aspect on request threads). We reuse it to carry the billing identity onto diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java index bdecaacde..a24114eda 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java @@ -27,6 +27,21 @@ public class PolicyRun { private volatile WaitState waitState; private volatile String error; + + /** + * Stable, machine-readable failure code the client can branch on — e.g. an entitlement-limit + * sentinel ({@code PAYG_LIMIT_REACHED} / {@code FEATURE_DEGRADED}) propagated from a downstream + * tool call's 402 — alongside the human-readable {@link #error}. Null unless set on failure. + */ + private volatile String errorCode; + + /** + * For an entitlement-limit failure, whether the team was subscribed (over its spending cap) vs + * un-subscribed (free allowance spent) — taken from the blocking 402 body. Drives which + * usage-limit modal the client shows. Null unless {@link #errorCode} is an entitlement code. + */ + private volatile Boolean errorSubscribed; + private volatile List outputs = List.of(); private volatile Instant updatedAt = Instant.now(); @@ -61,6 +76,17 @@ public class PolicyRun { touch(); } + /** + * Fail with a stable {@code errorCode} the client can branch on (e.g. an entitlement-limit + * sentinel from a downstream 402), plus the optional {@code subscribed} flag from that + * response, in addition to the human-readable message. + */ + public synchronized void failWithCode(String message, String errorCode, Boolean subscribed) { + this.errorCode = errorCode; + this.errorSubscribed = subscribed; + fail(message); + } + public synchronized void waitForInput(WaitState wait) { this.waitState = wait; this.status = PolicyRunStatus.WAITING_FOR_INPUT; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java index bba12a3c5..61a6c61f6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java @@ -14,6 +14,8 @@ public record PolicyRunView( int currentStep, int stepCount, String error, + String errorCode, + Boolean errorSubscribed, List outputs) { public static PolicyRunView of(PolicyRun run) { @@ -23,6 +25,8 @@ public record PolicyRunView( run.getCurrentStep(), run.stepCount(), run.getError(), + run.getErrorCode(), + run.getErrorSubscribed(), run.getOutputs()); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index af9d7a427..ddb4eced3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -31,7 +31,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.MDC; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.FileStorage; @@ -171,6 +174,35 @@ class PolicyEngineTest { verify(taskManager, never()).setComplete(runId); } + @Test + void runBlockedByUsageLimit_surfacesErrorCodeAndSubscribed() throws Exception { + // A downstream tool call gets a 402 entitlement block. The run fails, but its errorCode + + // subscribed are taken from the 402 body so the client can pop the right usage-limit modal + // (the policy 402 happens server-side, out of reach of the apiClient interceptor). + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + String body = "{\"error\":\"PAYG_LIMIT_REACHED\",\"subscribed\":true}"; + when(internalApiClient.post(eq(ROTATE), any())) + .thenThrow( + HttpClientErrorException.create( + HttpStatus.PAYMENT_REQUIRED, + "Payment Required", + HttpHeaders.EMPTY, + body.getBytes(java.nio.charset.StandardCharsets.UTF_8), + java.nio.charset.StandardCharsets.UTF_8)); + + PolicyRun run = + engine.submit( + definition(new PipelineStep(ROTATE, Map.of())), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + + assertEquals(PolicyRunStatus.FAILED, run.getStatus()); + assertEquals("PAYG_LIMIT_REACHED", run.getErrorCode()); + assertEquals(Boolean.TRUE, run.getErrorSubscribed()); + } + @Test void runPolicyExecutesThePolicysPipeline() throws Exception { when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index 413fb9f12..54ebd24c0 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -299,6 +299,10 @@ public class EntitlementGuard implements HandlerInterceptor { deniedDegradedCounter.increment(); Map body = new LinkedHashMap<>(); body.put("error", "FEATURE_DEGRADED"); + // subscribed tells the client which usage-limit modal to show: a subscribed team is over + // its spending cap; an un-subscribed one has spent its free allowance. (PAYG_LIMIT_REACHED + // already carries this; mirror it here so the JWT/web path can pick the right modal too.) + body.put("subscribed", snapshot.subscribed()); body.put("missingGates", missingGates(required, snapshot.enabledGates())); body.put("state", snapshot.state().name()); body.put( diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index c692abbe3..d66e4b17f 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -308,6 +308,8 @@ class EntitlementGuardTest { JsonNode body = json.readTree(res.getContentAsByteArray()); assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED"); assertThat(body.get("state").asText()).isEqualTo("DEGRADED"); + // subscribed drives the client's modal choice (free-limit vs spend-cap). + assertThat(body.get("subscribed").asBoolean()).isFalse(); assertThat(body.get("capUnits").asLong()).isEqualTo(500L); assertThat(body.get("spendUnits").asLong()).isEqualTo(500L); assertThat(body.get("missingGates").isArray()).isTrue(); diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 434b84dfa..31175dbe2 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -31,6 +31,8 @@ export interface PolicyRunRecord { * which marks the policy's OUTPUT — not the input it ran on. */ outputFileIds?: string[]; error: string | null; + /** Stable backend failure code (e.g. an entitlement sentinel) when FAILED; null otherwise. */ + errorCode?: string | null; /** Epoch ms when the run was dispatched. */ startedAt: number; } diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 20d9fc4a2..64c177739 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -11,7 +11,7 @@ * in the run store), so re-renders and remounts don't re-fire. */ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useAllFiles, useFileManagement, @@ -24,7 +24,12 @@ import { getPolicyRun, downloadPolicyOutput, } from "@app/services/policyApi"; -import type { PolicyRunStatus } from "@app/services/policyPipeline"; +import type { + PolicyRunStatus, + PolicyRunView, + PolicyLimitReachedDetail, +} from "@app/services/policyPipeline"; +import { POLICY_LIMIT_REACHED_EVENT } from "@app/services/policyPipeline"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; @@ -69,6 +74,30 @@ export function usePolicyAutoRun(): void { const importing = useRef>(new Set()); const dispatching = useRef>(new Set()); + // A policy's tool calls run server-side, so a usage-limit 402 never reaches the apiClient + // interceptor (and thus never pops the modal that direct calls get). The backend surfaces the + // limit sentinel on the run's errorCode; when a run we polled finishes blocked, broadcast a + // window event. A saas-layer listener (which can read the wallet + open the modal — this + // proprietary hook can't import the saas modal API) decides free-limit vs spend-cap. Dedupe per + // run so a folder-watch burst opens the modal once, not once per file. + const firedLimitModal = useRef>(new Set()); + + const onRunFinished = useCallback((view: PolicyRunView) => { + const code = view.errorCode; + if (code !== "PAYG_LIMIT_REACHED" && code !== "FEATURE_DEGRADED") return; + if (firedLimitModal.current.has(view.runId)) return; + firedLimitModal.current.add(view.runId); + try { + window.dispatchEvent( + new CustomEvent(POLICY_LIMIT_REACHED_EVENT, { + detail: { subscribed: view.errorSubscribed ?? null }, + }), + ); + } catch { + // non-browser env (tests / SSR) — no-op. + } + }, []); + // Dispatch: for each active policy × each session file not yet run, fire a run. useEffect(() => { if (!POLICIES_ENABLED) return; @@ -116,9 +145,11 @@ export function usePolicyAutoRun(): void { for (const run of runs) { if (isTerminal(run.status) || polling.current.has(run.runId)) continue; polling.current.add(run.runId); - void poll(run.runId).finally(() => polling.current.delete(run.runId)); + void poll(run.runId, onRunFinished).finally(() => + polling.current.delete(run.runId), + ); } - }, [runs]); + }, [runs, onRunFinished]); // Import each completed run's outputs into the workspace (each output once), // so the enforced file appears in the app rather than only on the backend. @@ -305,8 +336,16 @@ export async function runPolicyOnFile( } } -/** Poll a run's status until it reaches a terminal state (or the cap). */ -async function poll(runId: string): Promise { +/** + * Poll a run's status until it reaches a terminal state (or the cap). Calls {@code onTerminal} once + * with the final view when it terminates — the caller uses that to pop the usage-limit modal when a + * run was blocked. Only runs polled this session fire it (terminal runs aren't re-polled), so a + * persisted failed run never re-triggers a modal on reload. + */ +async function poll( + runId: string, + onTerminal?: (view: PolicyRunView) => void, +): Promise { for (let i = 0; i < MAX_POLLS; i++) { await delay(POLL_MS); let view; @@ -319,7 +358,11 @@ async function poll(runId: string): Promise { status: view.status, outputs: view.outputs, error: view.error, + errorCode: view.errorCode ?? null, }); - if (isTerminal(view.status)) return; + if (isTerminal(view.status)) { + onTerminal?.(view); + return; + } } } diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index 29f38695a..3049ef28c 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -56,6 +56,24 @@ export interface BackendPolicy { output: BackendOutputSpec; } +/** + * Window event fired when a policy run is blocked by a usage limit (its tool call got a 402 + * entitlement sentinel). Dispatched from the proprietary auto-run poll; a saas-layer listener + * reads the wallet and opens the matching usage-limit modal. Kept here (proprietary) so both + * layers share one name — proprietary can't import the saas modal API directly. + */ +export const POLICY_LIMIT_REACHED_EVENT = "payg:policyLimitReached"; + +/** Detail carried on {@link POLICY_LIMIT_REACHED_EVENT}. */ +export interface PolicyLimitReachedDetail { + /** + * Whether the blocked team was subscribed (over its spending cap) vs un-subscribed (free + * allowance spent), from the blocking 402. The listener uses it to choose the spend-cap vs + * free-limit modal. Null when unknown → treat as free-limit. + */ + subscribed: boolean | null; +} + /** Lifecycle states of a backend run (mirrors PolicyRunStatus). */ export type PolicyRunStatus = | "PENDING" @@ -77,6 +95,17 @@ export interface PolicyRunView { currentStep: number; stepCount: number; error: string | null; + /** + * Stable failure code from the backend (e.g. an entitlement sentinel + * {@code PAYG_LIMIT_REACHED} / {@code FEATURE_DEGRADED} propagated from a + * downstream tool's 402). Null/absent for ordinary failures. + */ + errorCode?: string | null; + /** + * For an entitlement-limit failure, the {@code subscribed} flag from the blocking 402 — picks the + * spend-cap (true) vs free-limit (false) modal. Null/absent otherwise. + */ + errorSubscribed?: boolean | null; outputs: BackendResultFile[]; } diff --git a/frontend/editor/src/saas/components/UsageLimitModalHost.tsx b/frontend/editor/src/saas/components/UsageLimitModalHost.tsx index c6e2a2887..4c7802cac 100644 --- a/frontend/editor/src/saas/components/UsageLimitModalHost.tsx +++ b/frontend/editor/src/saas/components/UsageLimitModalHost.tsx @@ -5,6 +5,10 @@ import { FREE_LIMIT_MODAL_EVENT, SPEND_CAP_MODAL_EVENT, } from "@app/components/usageLimitModals"; +import { + POLICY_LIMIT_REACHED_EVENT, + type PolicyLimitReachedDetail, +} from "@app/services/policyPipeline"; /** * Always-mounted host for the usage-limit warning modals. Mount once (in @@ -12,6 +16,12 @@ import { * (see usageLimitModals.ts) fire their bridge events. Each modal is mounted * only while open, so it reads the wallet (and animates in) on open rather * than on app load. + * + *

Also bridges the policy auto-run path: a policy's tool calls run server-side, + * so their usage-limit 402 never reaches the apiClient interceptor that pops these + * modals for direct calls. The proprietary auto-run hook broadcasts {@link + * POLICY_LIMIT_REACHED_EVENT} (with the blocking 402's {@code subscribed} flag) + * instead; we open the matching modal here. */ export default function UsageLimitModalHost() { const [freeOpen, setFreeOpen] = useState(false); @@ -20,11 +30,19 @@ export default function UsageLimitModalHost() { useEffect(() => { const onFree = () => setFreeOpen(true); const onSpend = () => setSpendOpen(true); + const onPolicyLimit = (e: Event) => { + const subscribed = (e as CustomEvent).detail + ?.subscribed; + if (subscribed) setSpendOpen(true); + else setFreeOpen(true); + }; window.addEventListener(FREE_LIMIT_MODAL_EVENT, onFree); window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend); + window.addEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit); return () => { window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree); window.removeEventListener(SPEND_CAP_MODAL_EVENT, onSpend); + window.removeEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit); }; }, []); diff --git a/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx b/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx index 7db30887e..802583771 100644 --- a/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx +++ b/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx @@ -5,7 +5,7 @@ import CelebrationIcon from "@mui/icons-material/CelebrationOutlined"; import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -import { navigateToSettings } from "@app/utils/settingsNavigation"; +import { openPlanSettings } from "@app/utils/appSettings"; import { FreeMeterPanel, freeSnapshotFromWallet, @@ -69,7 +69,11 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) { const handleUpgrade = () => { onClose(); - navigateToSettings("plan"); + // Open the App Config modal AND select the Plan section. navigateToSettings("plan") only + // pushes a /settings/plan URL, which in SaaS opens config at its default section rather than + // the Plan page; openPlanSettings dispatches the appConfig:open + appConfig:navigate events the + // config modal actually listens for. + openPlanSettings(); }; return ( diff --git a/frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx b/frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx index c124b9bae..fbb54bc04 100644 --- a/frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx +++ b/frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx @@ -5,7 +5,7 @@ import TrendingUpIcon from "@mui/icons-material/TrendingUpOutlined"; import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -import { navigateToSettings } from "@app/utils/settingsNavigation"; +import { openPlanSettings } from "@app/utils/appSettings"; import { SpendCapMeterPanel, spendCapSnapshotFromWallet, @@ -69,7 +69,11 @@ export function SpendCapReachedModal({ onClose }: SpendCapReachedModalProps) { const handleRaiseCap = () => { onClose(); - navigateToSettings("plan"); + // Open the App Config modal AND select the Plan section. navigateToSettings("plan") only + // pushes a /settings/plan URL, which in SaaS opens config at its default section rather than + // the Plan page; openPlanSettings dispatches the appConfig:open + appConfig:navigate events the + // config modal actually listens for. + openPlanSettings(); }; return ( diff --git a/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts index c4dfac1cd..c64f263f4 100644 --- a/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts +++ b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts @@ -1,21 +1,13 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -// Mock the toast layer and openPlanSettings so we can assert what the -// handler dispatches without needing a real DOM context for the toast -// portal. Mocks are hoisted by vitest so the module under test imports -// these in place of the real implementations. -vi.mock("@app/components/toast", () => ({ - alert: vi.fn(), -})); -vi.mock("@app/utils/appSettings", () => ({ - openPlanSettings: vi.fn(), -})); - -import { alert } from "@app/components/toast"; -import { openPlanSettings } from "@app/utils/appSettings"; +import { + FREE_LIMIT_MODAL_EVENT, + SPEND_CAP_MODAL_EVENT, +} from "@app/components/usageLimitModals"; import { classifyPaygError, extractSignupCategory, + extractSubscribed, handlePaygError, } from "@app/services/paygErrorInterceptor"; @@ -26,6 +18,7 @@ describe("classifyPaygError", () => { status: 402, data: { error: "FEATURE_DEGRADED", + subscribed: false, missingGates: ["AUTOMATION"], state: "DEGRADED", periodEnd: "2026-06-30", @@ -37,6 +30,16 @@ describe("classifyPaygError", () => { expect(classifyPaygError(err)).toBe("FEATURE_DEGRADED"); }); + it("returns PAYG_LIMIT_REACHED for 402 + error sentinel (API-key path)", () => { + const err = { + response: { + status: 402, + data: { error: "PAYG_LIMIT_REACHED", subscribed: true }, + }, + }; + expect(classifyPaygError(err)).toBe("PAYG_LIMIT_REACHED"); + }); + it("returns SIGNUP_REQUIRED for 401 + error sentinel", () => { const err = { response: { @@ -57,7 +60,7 @@ describe("classifyPaygError", () => { expect(classifyPaygError(err)).toBeNull(); }); - it("returns null for 402 without the FEATURE_DEGRADED sentinel", () => { + it("returns null for 402 without a known sentinel", () => { const err = { response: { status: 402, data: { error: "Payment required" } }, }; @@ -116,33 +119,89 @@ describe("extractSignupCategory", () => { }); }); -describe("handlePaygError", () => { +describe("extractSubscribed", () => { + it("returns the boolean when present", () => { + expect( + extractSubscribed({ response: { data: { subscribed: true } } }), + ).toBe(true); + expect( + extractSubscribed({ response: { data: { subscribed: false } } }), + ).toBe(false); + }); + + it("returns null when missing or wrong type", () => { + expect(extractSubscribed(null)).toBeNull(); + expect(extractSubscribed({})).toBeNull(); + expect(extractSubscribed({ response: { data: {} } })).toBeNull(); + expect( + extractSubscribed({ response: { data: { subscribed: "yes" } } }), + ).toBeNull(); + }); +}); + +describe("handlePaygError — usage-limit modals", () => { + let freeOpened: number; + let spendOpened: number; + const onFree = () => (freeOpened += 1); + const onSpend = () => (spendOpened += 1); + beforeEach(() => { vi.clearAllMocks(); + freeOpened = 0; + spendOpened = 0; + window.addEventListener(FREE_LIMIT_MODAL_EVENT, onFree); + window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend); }); - it("shows the persistent upgrade toast on FEATURE_DEGRADED", () => { + afterEach(() => { + window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree); + window.removeEventListener(SPEND_CAP_MODAL_EVENT, onSpend); + }); + + it("FEATURE_DEGRADED + unsubscribed → opens the free-limit modal (no spend-cap)", () => { + handlePaygError("FEATURE_DEGRADED", { + response: { status: 402, data: { error: "FEATURE_DEGRADED", subscribed: false } }, + }); + expect(freeOpened).toBe(1); + expect(spendOpened).toBe(0); + }); + + it("FEATURE_DEGRADED + subscribed → opens the spend-cap modal", () => { + handlePaygError("FEATURE_DEGRADED", { + response: { status: 402, data: { error: "FEATURE_DEGRADED", subscribed: true } }, + }); + expect(spendOpened).toBe(1); + expect(freeOpened).toBe(0); + }); + + it("PAYG_LIMIT_REACHED + subscribed → opens the spend-cap modal", () => { + handlePaygError("PAYG_LIMIT_REACHED", { + response: { status: 402, data: { error: "PAYG_LIMIT_REACHED", subscribed: true } }, + }); + expect(spendOpened).toBe(1); + expect(freeOpened).toBe(0); + }); + + it("PAYG_LIMIT_REACHED + unsubscribed → opens the free-limit modal", () => { + handlePaygError("PAYG_LIMIT_REACHED", { + response: { status: 402, data: { error: "PAYG_LIMIT_REACHED", subscribed: false } }, + }); + expect(freeOpened).toBe(1); + expect(spendOpened).toBe(0); + }); + + it("defaults to the free-limit modal when subscribed is absent", () => { handlePaygError("FEATURE_DEGRADED", { response: { status: 402, data: { error: "FEATURE_DEGRADED" } }, }); - expect(alert).toHaveBeenCalledTimes(1); - const opts = vi.mocked(alert).mock.calls[0][0]; - expect(opts.alertType).toBe("warning"); - expect(opts.isPersistentPopup).toBe(true); - expect(opts.buttonText).toBe("Go to billing"); - // Body should reference the 500-op free monthly allowance so the - // user understands what they hit. - expect(String(opts.body)).toMatch(/500/); + expect(freeOpened).toBe(1); + expect(spendOpened).toBe(0); }); +}); - it("invoking the toast's buttonCallback opens the Plan settings tab", () => { - handlePaygError("FEATURE_DEGRADED", { - response: { status: 402, data: { error: "FEATURE_DEGRADED" } }, - }); - const opts = vi.mocked(alert).mock.calls[0][0]; - expect(opts.buttonCallback).toBeDefined(); - opts.buttonCallback?.(); - expect(openPlanSettings).toHaveBeenCalledTimes(1); +describe("handlePaygError — signup", () => { + beforeEach(() => { + vi.clearAllMocks(); }); it("dispatches payg:signupRequired on SIGNUP_REQUIRED with category in detail", () => { @@ -158,8 +217,6 @@ describe("handlePaygError", () => { expect(handler).toHaveBeenCalledTimes(1); const ev = handler.mock.calls[0][0] as CustomEvent; expect(ev.detail).toEqual({ category: "AUTOMATION" }); - // No toast for SIGNUP_REQUIRED — the modal carries the message. - expect(alert).not.toHaveBeenCalled(); } finally { window.removeEventListener("payg:signupRequired", handler); } diff --git a/frontend/editor/src/saas/services/paygErrorInterceptor.ts b/frontend/editor/src/saas/services/paygErrorInterceptor.ts index 76ae0f4f3..0f6a29ffb 100644 --- a/frontend/editor/src/saas/services/paygErrorInterceptor.ts +++ b/frontend/editor/src/saas/services/paygErrorInterceptor.ts @@ -1,36 +1,40 @@ /** * Classifies and reacts to PAYG-specific error responses surfaced by the - * backend's {@code EntitlementGuard} (Wave 1 BE on PR #6574). Two sentinels - * are recognised: + * backend's {@code EntitlementGuard}. Three sentinels are recognised: * *

    - *
  • {@code 402 FEATURE_DEGRADED} — free-tier user has burned through - * their 500-op monthly allowance. Surface a toast that nudges them to - * the Plan tab so they can upgrade.
  • + *
  • {@code 402 FEATURE_DEGRADED} — an authenticated (JWT/web) team hit a + * billable feature it no longer has: a free team that spent its one-time + * allowance, or a subscribed team over its monthly spending cap. Which + * one is told by the {@code subscribed} field on the body.
  • + *
  • {@code 402 PAYG_LIMIT_REACHED} — same situation reached via an API key + * (programmatic client). Also carries {@code subscribed}.
  • *
  • {@code 401 SIGNUP_REQUIRED} — anonymous (guest) user hit a billable - * endpoint. Open a modal explaining why they need a real account and - * where their 500-op free monthly allowance comes in. The body's - * {@code category} field ({@code AI}, {@code AUTOMATION}, {@code API}) - * feeds the modal title so the user understands *which* feature they - * just hit. We dispatch a {@code CustomEvent} rather than rendering - * directly from this module because the apiClient is created outside - * the React tree and can't import JSX; the listener lives on a - * bootstrap component mounted near the app root.
  • + * endpoint. Opens the signup modal (a different flow) via a + * {@code CustomEvent}. *
* + * For the two limit sentinels we pop the matching usage-limit modal (free → + * "free limit reached", subscribed → "spend cap reached") and show NO toast — + * the modal is the actionable surface. The modals read the live wallet for the + * usage figures, so we only need to decide which one to open. + * * The classifier is exported separately from the handler so unit tests can - * exercise the parsing logic without touching the toast / event side - * effects. + * exercise the parsing logic without touching the modal side effects. */ -import { alert } from "@app/components/toast"; -import i18n from "@app/i18n"; -import { openPlanSettings } from "@app/utils/appSettings"; +import { + openFreeLimitModal, + openSpendCapModal, +} from "@app/components/usageLimitModals"; /** * Possible PAYG entitlement sentinels the EntitlementGuard returns. * {@code null} when the error is not a PAYG entitlement response. */ -export type PaygErrorKind = "FEATURE_DEGRADED" | "SIGNUP_REQUIRED"; +export type PaygErrorKind = + | "FEATURE_DEGRADED" + | "PAYG_LIMIT_REACHED" + | "SIGNUP_REQUIRED"; /** * Detail payload broadcast on {@code payg:signupRequired} when an anonymous @@ -65,6 +69,9 @@ export function classifyPaygError(error: unknown): PaygErrorKind | null { if (status === 402 && sentinel === "FEATURE_DEGRADED") { return "FEATURE_DEGRADED"; } + if (status === 402 && sentinel === "PAYG_LIMIT_REACHED") { + return "PAYG_LIMIT_REACHED"; + } if (status === 401 && sentinel === "SIGNUP_REQUIRED") { return "SIGNUP_REQUIRED"; } @@ -83,35 +90,46 @@ export function extractSignupCategory(error: unknown): string | null { } /** - * Surface the appropriate UI for a classified PAYG error. Toast for - * {@code FEATURE_DEGRADED}, modal-via-CustomEvent for {@code SIGNUP_REQUIRED}. + * Extract {@code data.subscribed} (a boolean) from an axios error. Returns + * {@code null} when absent so the caller can apply a default. A subscribed + * team that hits a limit is over its spending cap; an un-subscribed one has + * spent its free allowance. + */ +export function extractSubscribed(error: unknown): boolean | null { + if (!error || typeof error !== "object") return null; + const response = (error as { response?: unknown }).response; + if (!response || typeof response !== "object") return null; + const data = (response as { data?: unknown }).data; + if (!data || typeof data !== "object") return null; + const subscribed = (data as { subscribed?: unknown }).subscribed; + return typeof subscribed === "boolean" ? subscribed : null; +} + +/** + * Surface the appropriate UI for a classified PAYG error. * - * Idempotent / safe to call multiple times — the toast layer coalesces - * duplicates by (alertType, title, body) and the modal listener already - * dedupes by its own opened-state. Suppress-respecting: if the caller - * passed {@code suppressErrorToast: true} on the axios config (the - * established pattern for component-level error handling), we still fire - * the PAYG UI because these are user-facing gates, not transient - * error toasts — the suppression flag was for the *generic* error toast, - * which we're replacing with something more actionable. + *
    + *
  • {@code FEATURE_DEGRADED} / {@code PAYG_LIMIT_REACHED} — pop the + * usage-limit modal (spend-cap when subscribed, free-limit otherwise) and + * show no toast. Defaults to the free-limit modal if {@code subscribed} + * is absent (most accounts at launch are free tier).
  • + *
  • {@code SIGNUP_REQUIRED} — dispatch {@code payg:signupRequired} so the + * signup-bootstrap listener opens its modal.
  • + *
+ * + * Safe to call multiple times — the modal hosts dedupe by their own open state. + * Suppress-respecting in spirit: these are user-facing gates, not transient + * error toasts, so we surface the modal even when the caller passed + * {@code suppressErrorToast} (that flag was for the generic error toast we are + * replacing with something more actionable). */ export function handlePaygError(kind: PaygErrorKind, error: unknown): void { - if (kind === "FEATURE_DEGRADED") { - alert({ - alertType: "warning", - title: i18n.t( - "payg.exhausted.title", - "You've hit your free monthly limit", - ), - body: i18n.t( - "payg.exhausted.body", - "You've used your free 500 operations this month. Upgrade to Processor to keep going.", - ), - buttonText: i18n.t("payg.exhausted.cta", "Go to billing"), - buttonCallback: () => openPlanSettings(), - isPersistentPopup: true, - location: "bottom-right", - }); + if (kind === "FEATURE_DEGRADED" || kind === "PAYG_LIMIT_REACHED") { + if (extractSubscribed(error) === true) { + openSpendCapModal(); + } else { + openFreeLimitModal(); + } return; }