fe(payg): show the usage-limit modal (not a toast) when the limit is hit
Wires the backend's 402 entitlement responses to Ethan's usage-limit modals (#6623) instead of the old upgrade toast. On a limit hit the apiClient interceptor now pops the matching modal and shows no toast: - subscribed team over its spending cap -> openSpendCapModal() - un-subscribed team over its free grant -> openFreeLimitModal() paygErrorInterceptor: - classifyPaygError also recognises 402 PAYG_LIMIT_REACHED (the API-key path), alongside the existing FEATURE_DEGRADED (JWT/web path); both pick the modal. - handlePaygError fires the modal via the subscribed flag (extractSubscribed), defaulting to the free-limit modal when absent. No toast. SIGNUP_REQUIRED (anonymous) keeps its existing signup-modal event. EntitlementGuard: FEATURE_DEGRADED 402 body now carries `subscribed` (it already did on PAYG_LIMIT_REACHED), so the web path can choose free-limit vs spend-cap. Tests: interceptor opens the right modal per sentinel × subscribed (and defaults to free when subscribed is absent); guard asserts `subscribed` on the body. :saas:test, spotless, eslint, saas tsc, and the interceptor vitest all green. Covers direct UI/API tool calls (the apiClient catch point). The async policy-run path (402 happens server-side; FE polls run status) is a separate follow-up.
This commit is contained in:
@@ -299,6 +299,10 @@ public class EntitlementGuard implements HandlerInterceptor {
|
||||
deniedDegradedCounter.increment();
|
||||
Map<String, Object> 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(
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@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.</li>
|
||||
* <li>{@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.</li>
|
||||
* <li>{@code 402 PAYG_LIMIT_REACHED} — same situation reached via an API key
|
||||
* (programmatic client). Also carries {@code subscribed}.</li>
|
||||
* <li>{@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.</li>
|
||||
* endpoint. Opens the signup modal (a different flow) via a
|
||||
* {@code CustomEvent}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* 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.
|
||||
* <ul>
|
||||
* <li>{@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).</li>
|
||||
* <li>{@code SIGNUP_REQUIRED} — dispatch {@code payg:signupRequired} so the
|
||||
* signup-bootstrap listener opens its modal.</li>
|
||||
* </ul>
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user