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 413fb9f12d..54ebd24c04 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 c692abbe37..d66e4b17fe 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/saas/services/paygErrorInterceptor.test.ts b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts index c4dfac1cd1..c64f263f46 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 76ae0f4f36..0f6a29ffb6 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: * * * + * 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. + * + * + * 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; }