mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
fe(payg): show the usage-limit modal when the limit is hit (direct + policy) (#6626)
This commit is contained in:
+64
@@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClientResponseException;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -199,6 +200,26 @@ public class PolicyEngine {
|
|||||||
e.getMessage());
|
e.getMessage());
|
||||||
run.fail(message);
|
run.fail(message);
|
||||||
taskManager.setError(runId, 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) {
|
} catch (Exception e) {
|
||||||
String message = "Policy run failed: " + e.getMessage();
|
String message = "Policy run failed: " + e.getMessage();
|
||||||
log.error("Policy run {} failed", runId, e);
|
log.error("Policy run {} failed", runId, e);
|
||||||
@@ -281,6 +302,49 @@ public class PolicyEngine {
|
|||||||
e.getEndpointPath(), e.getReadTimeout().toSeconds());
|
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
|
* 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
|
* controller audit aspect on request threads). We reuse it to carry the billing identity onto
|
||||||
|
|||||||
+26
@@ -27,6 +27,21 @@ public class PolicyRun {
|
|||||||
|
|
||||||
private volatile WaitState waitState;
|
private volatile WaitState waitState;
|
||||||
private volatile String error;
|
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<ResultFile> outputs = List.of();
|
private volatile List<ResultFile> outputs = List.of();
|
||||||
private volatile Instant updatedAt = Instant.now();
|
private volatile Instant updatedAt = Instant.now();
|
||||||
|
|
||||||
@@ -61,6 +76,17 @@ public class PolicyRun {
|
|||||||
touch();
|
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) {
|
public synchronized void waitForInput(WaitState wait) {
|
||||||
this.waitState = wait;
|
this.waitState = wait;
|
||||||
this.status = PolicyRunStatus.WAITING_FOR_INPUT;
|
this.status = PolicyRunStatus.WAITING_FOR_INPUT;
|
||||||
|
|||||||
+4
@@ -14,6 +14,8 @@ public record PolicyRunView(
|
|||||||
int currentStep,
|
int currentStep,
|
||||||
int stepCount,
|
int stepCount,
|
||||||
String error,
|
String error,
|
||||||
|
String errorCode,
|
||||||
|
Boolean errorSubscribed,
|
||||||
List<ResultFile> outputs) {
|
List<ResultFile> outputs) {
|
||||||
|
|
||||||
public static PolicyRunView of(PolicyRun run) {
|
public static PolicyRunView of(PolicyRun run) {
|
||||||
@@ -23,6 +25,8 @@ public record PolicyRunView(
|
|||||||
run.getCurrentStep(),
|
run.getCurrentStep(),
|
||||||
run.stepCount(),
|
run.stepCount(),
|
||||||
run.getError(),
|
run.getError(),
|
||||||
|
run.getErrorCode(),
|
||||||
|
run.getErrorSubscribed(),
|
||||||
run.getOutputs());
|
run.getOutputs());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -31,7 +31,10 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
|
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.service.FileStorage;
|
import stirling.software.common.service.FileStorage;
|
||||||
@@ -171,6 +174,35 @@ class PolicyEngineTest {
|
|||||||
verify(taskManager, never()).setComplete(runId);
|
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
|
@Test
|
||||||
void runPolicyExecutesThePolicysPipeline() throws Exception {
|
void runPolicyExecutesThePolicysPipeline() throws Exception {
|
||||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||||
|
|||||||
@@ -299,6 +299,10 @@ public class EntitlementGuard implements HandlerInterceptor {
|
|||||||
deniedDegradedCounter.increment();
|
deniedDegradedCounter.increment();
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
body.put("error", "FEATURE_DEGRADED");
|
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("missingGates", missingGates(required, snapshot.enabledGates()));
|
||||||
body.put("state", snapshot.state().name());
|
body.put("state", snapshot.state().name());
|
||||||
body.put(
|
body.put(
|
||||||
|
|||||||
+2
@@ -308,6 +308,8 @@ class EntitlementGuardTest {
|
|||||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||||
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
|
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
|
||||||
assertThat(body.get("state").asText()).isEqualTo("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("capUnits").asLong()).isEqualTo(500L);
|
||||||
assertThat(body.get("spendUnits").asLong()).isEqualTo(500L);
|
assertThat(body.get("spendUnits").asLong()).isEqualTo(500L);
|
||||||
assertThat(body.get("missingGates").isArray()).isTrue();
|
assertThat(body.get("missingGates").isArray()).isTrue();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface PolicyRunRecord {
|
|||||||
* which marks the policy's OUTPUT — not the input it ran on. */
|
* which marks the policy's OUTPUT — not the input it ran on. */
|
||||||
outputFileIds?: string[];
|
outputFileIds?: string[];
|
||||||
error: string | null;
|
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. */
|
/** Epoch ms when the run was dispatched. */
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* in the run store), so re-renders and remounts don't re-fire.
|
* 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 {
|
import {
|
||||||
useAllFiles,
|
useAllFiles,
|
||||||
useFileManagement,
|
useFileManagement,
|
||||||
@@ -24,7 +24,12 @@ import {
|
|||||||
getPolicyRun,
|
getPolicyRun,
|
||||||
downloadPolicyOutput,
|
downloadPolicyOutput,
|
||||||
} from "@app/services/policyApi";
|
} 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 type { FileId } from "@app/types/file";
|
||||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||||
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
||||||
@@ -69,6 +74,30 @@ export function usePolicyAutoRun(): void {
|
|||||||
const importing = useRef<Set<string>>(new Set());
|
const importing = useRef<Set<string>>(new Set());
|
||||||
const dispatching = 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.
|
// Dispatch: for each active policy × each session file not yet run, fire a run.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!POLICIES_ENABLED) return;
|
if (!POLICIES_ENABLED) return;
|
||||||
@@ -116,9 +145,11 @@ export function usePolicyAutoRun(): void {
|
|||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
if (isTerminal(run.status) || polling.current.has(run.runId)) continue;
|
if (isTerminal(run.status) || polling.current.has(run.runId)) continue;
|
||||||
polling.current.add(run.runId);
|
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),
|
// 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.
|
// 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++) {
|
for (let i = 0; i < MAX_POLLS; i++) {
|
||||||
await delay(POLL_MS);
|
await delay(POLL_MS);
|
||||||
let view;
|
let view;
|
||||||
@@ -319,7 +358,11 @@ async function poll(runId: string): Promise<void> {
|
|||||||
status: view.status,
|
status: view.status,
|
||||||
outputs: view.outputs,
|
outputs: view.outputs,
|
||||||
error: view.error,
|
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;
|
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). */
|
/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */
|
||||||
export type PolicyRunStatus =
|
export type PolicyRunStatus =
|
||||||
| "PENDING"
|
| "PENDING"
|
||||||
@@ -77,6 +95,17 @@ export interface PolicyRunView {
|
|||||||
currentStep: number;
|
currentStep: number;
|
||||||
stepCount: number;
|
stepCount: number;
|
||||||
error: string | null;
|
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[];
|
outputs: BackendResultFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
FREE_LIMIT_MODAL_EVENT,
|
FREE_LIMIT_MODAL_EVENT,
|
||||||
SPEND_CAP_MODAL_EVENT,
|
SPEND_CAP_MODAL_EVENT,
|
||||||
} from "@app/components/usageLimitModals";
|
} 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
|
* 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
|
* (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
|
* only while open, so it reads the wallet (and animates in) on open rather
|
||||||
* than on app load.
|
* 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() {
|
export default function UsageLimitModalHost() {
|
||||||
const [freeOpen, setFreeOpen] = useState(false);
|
const [freeOpen, setFreeOpen] = useState(false);
|
||||||
@@ -20,11 +30,19 @@ export default function UsageLimitModalHost() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFree = () => setFreeOpen(true);
|
const onFree = () => setFreeOpen(true);
|
||||||
const onSpend = () => setSpendOpen(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(FREE_LIMIT_MODAL_EVENT, onFree);
|
||||||
window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
|
window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
|
||||||
|
window.addEventListener(POLICY_LIMIT_REACHED_EVENT, onPolicyLimit);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
|
window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
|
||||||
window.removeEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
|
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 AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||||
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||||
import { navigateToSettings } from "@app/utils/settingsNavigation";
|
import { openPlanSettings } from "@app/utils/appSettings";
|
||||||
import {
|
import {
|
||||||
FreeMeterPanel,
|
FreeMeterPanel,
|
||||||
freeSnapshotFromWallet,
|
freeSnapshotFromWallet,
|
||||||
@@ -69,7 +69,11 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) {
|
|||||||
|
|
||||||
const handleUpgrade = () => {
|
const handleUpgrade = () => {
|
||||||
onClose();
|
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 (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import TrendingUpIcon from "@mui/icons-material/TrendingUpOutlined";
|
|||||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||||
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||||
import { navigateToSettings } from "@app/utils/settingsNavigation";
|
import { openPlanSettings } from "@app/utils/appSettings";
|
||||||
import {
|
import {
|
||||||
SpendCapMeterPanel,
|
SpendCapMeterPanel,
|
||||||
spendCapSnapshotFromWallet,
|
spendCapSnapshotFromWallet,
|
||||||
@@ -69,7 +69,11 @@ export function SpendCapReachedModal({ onClose }: SpendCapReachedModalProps) {
|
|||||||
|
|
||||||
const handleRaiseCap = () => {
|
const handleRaiseCap = () => {
|
||||||
onClose();
|
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 (
|
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
|
import {
|
||||||
// handler dispatches without needing a real DOM context for the toast
|
FREE_LIMIT_MODAL_EVENT,
|
||||||
// portal. Mocks are hoisted by vitest so the module under test imports
|
SPEND_CAP_MODAL_EVENT,
|
||||||
// these in place of the real implementations.
|
} from "@app/components/usageLimitModals";
|
||||||
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 {
|
import {
|
||||||
classifyPaygError,
|
classifyPaygError,
|
||||||
extractSignupCategory,
|
extractSignupCategory,
|
||||||
|
extractSubscribed,
|
||||||
handlePaygError,
|
handlePaygError,
|
||||||
} from "@app/services/paygErrorInterceptor";
|
} from "@app/services/paygErrorInterceptor";
|
||||||
|
|
||||||
@@ -26,6 +18,7 @@ describe("classifyPaygError", () => {
|
|||||||
status: 402,
|
status: 402,
|
||||||
data: {
|
data: {
|
||||||
error: "FEATURE_DEGRADED",
|
error: "FEATURE_DEGRADED",
|
||||||
|
subscribed: false,
|
||||||
missingGates: ["AUTOMATION"],
|
missingGates: ["AUTOMATION"],
|
||||||
state: "DEGRADED",
|
state: "DEGRADED",
|
||||||
periodEnd: "2026-06-30",
|
periodEnd: "2026-06-30",
|
||||||
@@ -37,6 +30,16 @@ describe("classifyPaygError", () => {
|
|||||||
expect(classifyPaygError(err)).toBe("FEATURE_DEGRADED");
|
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", () => {
|
it("returns SIGNUP_REQUIRED for 401 + error sentinel", () => {
|
||||||
const err = {
|
const err = {
|
||||||
response: {
|
response: {
|
||||||
@@ -57,7 +60,7 @@ describe("classifyPaygError", () => {
|
|||||||
expect(classifyPaygError(err)).toBeNull();
|
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 = {
|
const err = {
|
||||||
response: { status: 402, data: { error: "Payment required" } },
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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", {
|
handlePaygError("FEATURE_DEGRADED", {
|
||||||
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
|
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
|
||||||
});
|
});
|
||||||
expect(alert).toHaveBeenCalledTimes(1);
|
expect(freeOpened).toBe(1);
|
||||||
const opts = vi.mocked(alert).mock.calls[0][0];
|
expect(spendOpened).toBe(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/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invoking the toast's buttonCallback opens the Plan settings tab", () => {
|
describe("handlePaygError — signup", () => {
|
||||||
handlePaygError("FEATURE_DEGRADED", {
|
beforeEach(() => {
|
||||||
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
|
vi.clearAllMocks();
|
||||||
});
|
|
||||||
const opts = vi.mocked(alert).mock.calls[0][0];
|
|
||||||
expect(opts.buttonCallback).toBeDefined();
|
|
||||||
opts.buttonCallback?.();
|
|
||||||
expect(openPlanSettings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches payg:signupRequired on SIGNUP_REQUIRED with category in detail", () => {
|
it("dispatches payg:signupRequired on SIGNUP_REQUIRED with category in detail", () => {
|
||||||
@@ -158,8 +217,6 @@ describe("handlePaygError", () => {
|
|||||||
expect(handler).toHaveBeenCalledTimes(1);
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
const ev = handler.mock.calls[0][0] as CustomEvent;
|
const ev = handler.mock.calls[0][0] as CustomEvent;
|
||||||
expect(ev.detail).toEqual({ category: "AUTOMATION" });
|
expect(ev.detail).toEqual({ category: "AUTOMATION" });
|
||||||
// No toast for SIGNUP_REQUIRED — the modal carries the message.
|
|
||||||
expect(alert).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
} finally {
|
||||||
window.removeEventListener("payg:signupRequired", handler);
|
window.removeEventListener("payg:signupRequired", handler);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Classifies and reacts to PAYG-specific error responses surfaced by the
|
* Classifies and reacts to PAYG-specific error responses surfaced by the
|
||||||
* backend's {@code EntitlementGuard} (Wave 1 BE on PR #6574). Two sentinels
|
* backend's {@code EntitlementGuard}. Three sentinels are recognised:
|
||||||
* are recognised:
|
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code 402 FEATURE_DEGRADED} — free-tier user has burned through
|
* <li>{@code 402 FEATURE_DEGRADED} — an authenticated (JWT/web) team hit a
|
||||||
* their 500-op monthly allowance. Surface a toast that nudges them to
|
* billable feature it no longer has: a free team that spent its one-time
|
||||||
* the Plan tab so they can upgrade.</li>
|
* 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
|
* <li>{@code 401 SIGNUP_REQUIRED} — anonymous (guest) user hit a billable
|
||||||
* endpoint. Open a modal explaining why they need a real account and
|
* endpoint. Opens the signup modal (a different flow) via a
|
||||||
* where their 500-op free monthly allowance comes in. The body's
|
* {@code CustomEvent}.</li>
|
||||||
* {@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>
|
|
||||||
* </ul>
|
* </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
|
* The classifier is exported separately from the handler so unit tests can
|
||||||
* exercise the parsing logic without touching the toast / event side
|
* exercise the parsing logic without touching the modal side effects.
|
||||||
* effects.
|
|
||||||
*/
|
*/
|
||||||
import { alert } from "@app/components/toast";
|
import {
|
||||||
import i18n from "@app/i18n";
|
openFreeLimitModal,
|
||||||
import { openPlanSettings } from "@app/utils/appSettings";
|
openSpendCapModal,
|
||||||
|
} from "@app/components/usageLimitModals";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Possible PAYG entitlement sentinels the EntitlementGuard returns.
|
* Possible PAYG entitlement sentinels the EntitlementGuard returns.
|
||||||
* {@code null} when the error is not a PAYG entitlement response.
|
* {@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
|
* 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") {
|
if (status === 402 && sentinel === "FEATURE_DEGRADED") {
|
||||||
return "FEATURE_DEGRADED";
|
return "FEATURE_DEGRADED";
|
||||||
}
|
}
|
||||||
|
if (status === 402 && sentinel === "PAYG_LIMIT_REACHED") {
|
||||||
|
return "PAYG_LIMIT_REACHED";
|
||||||
|
}
|
||||||
if (status === 401 && sentinel === "SIGNUP_REQUIRED") {
|
if (status === 401 && sentinel === "SIGNUP_REQUIRED") {
|
||||||
return "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
|
* Extract {@code data.subscribed} (a boolean) from an axios error. Returns
|
||||||
* {@code FEATURE_DEGRADED}, modal-via-CustomEvent for {@code SIGNUP_REQUIRED}.
|
* {@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
|
* <ul>
|
||||||
* duplicates by (alertType, title, body) and the modal listener already
|
* <li>{@code FEATURE_DEGRADED} / {@code PAYG_LIMIT_REACHED} — pop the
|
||||||
* dedupes by its own opened-state. Suppress-respecting: if the caller
|
* usage-limit modal (spend-cap when subscribed, free-limit otherwise) and
|
||||||
* passed {@code suppressErrorToast: true} on the axios config (the
|
* show no toast. Defaults to the free-limit modal if {@code subscribed}
|
||||||
* established pattern for component-level error handling), we still fire
|
* is absent (most accounts at launch are free tier).</li>
|
||||||
* the PAYG UI because these are user-facing gates, not transient
|
* <li>{@code SIGNUP_REQUIRED} — dispatch {@code payg:signupRequired} so the
|
||||||
* error toasts — the suppression flag was for the *generic* error toast,
|
* signup-bootstrap listener opens its modal.</li>
|
||||||
* which we're replacing with something more actionable.
|
* </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 {
|
export function handlePaygError(kind: PaygErrorKind, error: unknown): void {
|
||||||
if (kind === "FEATURE_DEGRADED") {
|
if (kind === "FEATURE_DEGRADED" || kind === "PAYG_LIMIT_REACHED") {
|
||||||
alert({
|
if (extractSubscribed(error) === true) {
|
||||||
alertType: "warning",
|
openSpendCapModal();
|
||||||
title: i18n.t(
|
} else {
|
||||||
"payg.exhausted.title",
|
openFreeLimitModal();
|
||||||
"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",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user