fe(payg): show the usage-limit modal when the limit is hit (direct + policy) (#6626)

This commit is contained in:
ConnorYoh
2026-06-11 21:28:44 +01:00
committed by GitHub
parent 6f1c19c179
commit 22379fd5ab
14 changed files with 398 additions and 91 deletions
@@ -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;
}
@@ -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<Set<string>>(new Set());
const dispatching = useRef<Set<string>>(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<Set<string>>(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<PolicyLimitReachedDetail>(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<void> {
/**
* 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<void> {
for (let i = 0; i < MAX_POLLS; i++) {
await delay(POLL_MS);
let view;
@@ -319,7 +358,11 @@ async function poll(runId: string): Promise<void> {
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;
}
}
}
@@ -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[];
}
@@ -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.
*
* <p>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<PolicyLimitReachedDetail>).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);
};
}, []);
@@ -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 (
@@ -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 (
@@ -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;
}