PAYG: pay-as-you-go billing — metered automation/AI/API + one-time free grant (#6589)

## Summary

Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing
stays free forever; only **automation, AI, and API** usage is metered.
Every team gets a **one-time lifetime free grant** (default 500 PDFs)
before any billing; past that, a team adds a card and pays per metered
document, with a self-set monthly spending cap.

This branch combines and supersedes the in-flight BE (#6574) and FE
(#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on
`v3`), hardened into a single reviewable feature after a pre-merge
dead-code/security review.

## Billing model

- **Always free:** manual / JWT web-tool usage is `BYPASSED` — never
metered, no matter where it's triggered.
- **Billable categories:** `AUTOMATION`, `AI`, `API`.
- **One-time lifetime free grant** (`pricing_policy.free_tier_units`,
default 500): never resets, survives subscribing. It gates unsubscribed
teams (billable API calls hard-stop with a 402 once exhausted) and
decides the free-vs-paid split of every job.
- **Subscribed:** paid documents (beyond the grant) are metered to a
Stripe Billing Meter; an optional monthly spending cap degrades billable
categories when reached.
- **Dedup:** the same file pushed through several steps within a
workflow window counts **once** (lineage join), so API/AI chaining on
one file isn't double-charged.

## What's included

**Database** — Flyway migrations `V11`→`V21` with matching Supabase
twins: pricing policy + per-team sidecar (`payg_team_extensions`:
subscription id, Stripe customer, free-grant counter), append-only
`wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit
logs (`V15`), billing category (`V16`), one-time lifetime free grant
(`V19`), launch-grant seed (`V20`), drop of the unused
`wallet_category_summary` view (`V21`).

**Charge pipeline** — `PaygChargeInterceptor` (open/join a process,
split the free grant, write the ledger DEBIT), `JobChargeService`
(consume the grant under a row lock, restore it on a first-step refund,
meter only the paid portion on completion), `StaleJobCloser` fallback
(idempotent close → meter).

**Entitlement** — `EntitlementService` (per-team cached snapshot:
grant-gated for free teams, monthly-cap-gated for subscribed) +
`EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` /
`PAYG_LIMIT_REACHED`).

**Metering** — `PaygMeterReportingService` writes a durable
`payg_meter_event_log` row around every POST to the `meter-payg-units`
edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries
unposted events under the same idempotency key inside Stripe's 24h dedup
window.

**Billing facts** — `TeamBillingService` reads the synced `stripe.*`
mirror (subscription window, per-document rate; the unsubscribed-team
estimate resolves the rate by Price `lookup_key = plan:processor`).

**Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`,
`PATCH /api/v1/payg/cap`.

**Frontend** — PAYG Plan page (two-card free layout + subscribed views),
`useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and
a shared `SpendCapControl`, customer-portal link, 402/401 interceptor
toast, en-GB i18n. (Per-member usage shows each teammate's spend; the
activity feed is behind a flag until polished.)

**SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) —
`create-checkout-session`, `create-payg-team-subscription`,
`create-customer-portal-session`, `meter-payg-units`,
`payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync
`migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no
`STRIPE_PAYG_PRICE_ID_*` env vars).

## Release prerequisites (prod)

1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration
twins.
2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped**
backfill — `product`, `price`, `customer`, `subscription` only (not
`all`, which rate-limits).
3. Register 2 PAYG webhook endpoints (each its own signing secret):
`stripe-sync` (product/price/customer/subscription `.*`) and
`payg-subscription-webhook` (`customer.subscription.created`/`.deleted`
drive state; `.updated` + `invoice.*` observed). Keep the legacy
`stripe-webhook` only if credits/self-hosted flows still run.
4. Stripe Billing Meter: `event_name = payg_doc_units`, value key
`processed_documents`.
5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET`
(backend); the webhook signing secrets (edge fns). The default pricing
policy must point at the PAYG Stripe Price(s); `V20` seeds
`free_tier_units = 500`.

## Testing

- `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests
green, FE saas typecheck clean (the remaining errors are pre-existing
`proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode
suite + CI workflow included.

## Pre-merge review

An independent dead-code/security pass came back **clean on security**
(team-derived authz / no IDOR, leader-only cap mutation, no
billing-category downgrade, dev/mock hooks gated to
`import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open
metering by design). The dead/unwired code it flagged has been removed
in this branch (unenforced sub-cap control, an unused JDBC DAO + its
view, dead methods).

## Follow-ups (tracked, not blocking)

- **Enforce per-member sub-caps** — the control was removed because it
read for display but never gated a request; the per-member usage display
and `cap_units` column are retained for when enforcement is wired.
- **API/AI chaining billing model + `ProcessType` enum** — confirm
same-file dedup covers API chaining; define per-tool AI charging; decide
whether the unused enum values stay.
- **Activity feed** — hidden behind a flag until the meter-event surface
is polished.

---------

Co-authored-by: Reece <[email protected]>
This commit is contained in:
ConnorYoh
2026-06-11 15:56:01 +01:00
committed by GitHub
co-authored by Reece
parent 11ab762f57
commit cf513c255b
89 changed files with 11389 additions and 1034 deletions
@@ -3090,6 +3090,18 @@ integration = "Integration Configuration"
security = "Security Configuration"
system = "System Configuration"
[config.payg]
label = "Billing & usage"
section = "Pay-as-you-go"
[connectionMode.status]
localOffline = "Offline mode running"
localOnline = "Offline mode running"
saas = "Connected to Stirling Cloud"
selfhostedChecking = "Connected to self-hosted server (checking...)"
selfhostedOffline = "Self-hosted server unreachable"
selfhostedOnline = "Connected to self-hosted server"
[convert]
tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as"
autoRotate = "Auto Rotate"
@@ -5132,6 +5144,239 @@ title = "Page Ranges"
bullet1 = "<strong>all</strong> → selects all pages"
title = "Special Keywords"
[pageSelection.tooltip.syntax]
text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported."
title = "Syntax Basics"
[pageSelection.tooltip.syntax.bullets]
keywords = "Keywords: odd, even"
numbers = "Numbers/ranges: 5, 10-20"
progressions = "Progressions: 3n, 4n+1"
[pageSelection.tooltip.tips]
bullet1 = "Page numbers start from 1 (not 0)"
bullet2 = "Spaces are automatically removed"
bullet3 = "Invalid expressions are ignored"
text = "Keep these guidelines in mind:"
title = "Tips"
[payg]
subtitle = "Pay-as-you-go — you only pay for what you process. Billing period {{start}} {{end}}."
[payg.activity]
subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here."
title = "Recent billable activity"
units = "units"
viewAll = "View all"
[payg.cap]
amount = "Cap amount"
currency = "Currency"
custom = "Custom"
degradeAt = "Limit spend at"
degradeAtDesc = "Set below 100% to leave yourself headroom."
docsEstimate = "≈ {{docs}} processed PDFs / month"
docsRate = "at {{rate}} / PDF"
noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time."
noCapLabel = "No cap"
perMonth = "/ month"
preview = "{{money}} ≈ {{units}} PDFs per month"
previewNote = "At current pricing. Final translation happens server-side on save."
previewShort = "≈ {{units}} PDFs per month at current pricing."
save = "Update cap"
subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier."
title = "Monthly spending cap"
warnAt = "Warn me at"
warnAtDesc = "Notify when usage crosses this threshold."
[payg.checkout]
connecting = "Connecting to Stripe…"
errorTitle = "Stripe error"
[payg.checkout.error]
startFailed = "Couldn't start checkout session"
[payg.checkout.mock]
backend = "Backend is in mock mode — no real Stripe session was created."
continue = "Continue with mock subscription"
noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured."
title = "Stripe Embedded Checkout (mock mode)"
[payg.confirm]
body = "Your team can now run automation, AI, and API operations beyond your 500 free PDFs."
capValue = "{{symbol}}{{amount}} / month"
noCap = "No cap"
note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page."
summaryLabel = "Monthly ceiling"
title = "Welcome to the Processor plan"
[payg.error]
body = "We couldn't reach the billing service. Refresh the page to try again."
title = "Couldn't load your plan"
[payg.exhausted]
body = "You've used all 500 of your free PDFs. Upgrade to Processor to keep going."
cta = "Go to billing"
title = "You've used your free PDFs"
[payg.free.cta]
benefit1Body = "chain tools, schedule runs, batch process"
benefit1Title = "Automation pipelines"
benefit2Body = "summarise, classify, redact, AI-OCR"
benefit2Title = "AI tools"
benefit3Body = "call any Stirling endpoint programmatically"
benefit3Title = "API access"
button = "Turn on Processor →"
reassurance = "No minimum · Set a $0 cap to test · Cancel anytime"
subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control."
title = "Turn on the Processor plan"
[payg.free.editor]
eyebrow = "Editor plan · Always free"
[payg.free.explainer]
alwaysFreeBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from."
alwaysFreeForYouBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted."
alwaysFreeForYouLabel = "Always free for you"
alwaysFreeLabel = "Always free"
countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Past your free PDFs you'll need Processor."
countsLabel = "Counts toward your 500 free"
sharedBody = "Automation pipelines, AI tools, and API calls share a single one-time allowance of 500 free PDFs across the whole team. Once it's used up, ask your owner to enable Processor."
sharedLabel = "Shared with the team"
[payg.free.header]
subtitle = "Editor plan — manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}."
[payg.free.hero]
capSuffix = "/ {{limit}} free PDFs"
eyebrow = "Your free PDFs"
metaCategories = "Automation · AI · API requests"
neverResets = "One-time — never resets"
[payg.free.member]
body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like — automation, AI, and API access share the team's one-time allowance of 500 free PDFs."
ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like."
title = "Want to process more than your 500 free PDFs?"
[payg.free.proc]
eyebrow = "Processor plan · metered"
[payg.free.state]
approachingLimit = "Approaching limit"
limitReached = "Limit reached"
plentyLeft = "Plenty left"
[payg.gates]
ai = "AI tools (AI Create, suggestions, AI-OCR)"
automation = "Automations & pipelines"
client = "Browser-only tools (viewer, page editor, file management)"
offsite = "Server tools (compress, OCR, convert, watermark…)"
pauses = "pauses at cap"
subtitle = "Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised."
title = "What happens when the cap is reached"
[payg.member]
askLeader = "Only your team owner can change the cap."
[payg.members]
docs = "PDFs"
subtitle = "Billable PDFs each teammate has processed this period."
title = "Team member usage"
[payg.role]
leader = "Team owner"
member = "Member"
[payg.signupRequired]
body = "Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
cancel = "Not now"
cta = "Sign up free"
subtext = "Creating an account is free and takes a few seconds. No credit card required."
title = "Sign up to use {{category}}"
[payg.signupRequired.category]
ai = "AI features"
api = "this tool"
automation = "automations"
default = "this feature"
[payg.state]
degraded = "Cap reached"
full = "Healthy"
warned = "Approaching cap"
[payg.stripe]
open = "Open billing portal"
subtitle = "Receipts, invoices, payment method, billing currency."
title = "Manage billing in Stripe"
[payg.stripe.toast.unavailable]
body = "Billing portal isn't available right now. Try again in a moment."
title = "Billing portal unavailable"
[payg.upgrade]
closeAria = "Close"
[payg.upgrade.button]
back = "← Back"
cancel = "Cancel"
continue = "Continue →"
finish = "Finish"
[payg.upgrade.cap]
customPlaceholder = "Or enter your own amount ($0 keeps it free)"
help = "We'll never charge above this. Set $0 if you want to keep everything free while testing."
noCapHint = "You can still cancel anytime from the customer portal."
noCapLabel = "No cap — I'll manage spend manually"
perMonthSuffix = "/ month"
presetsAria = "Monthly cap preset"
title = "Set your monthly spend ceiling"
usdNote = "Estimated in USD. You can adjust your cap any time after subscribing — in your own currency."
[payg.upgrade.checkout]
capLabel = "Monthly ceiling:"
capValue = "{{symbol}}{{amount}} / month"
edit = "Edit"
help = "Stripe handles your card details. Stirling never sees them."
loading = "Loading checkout…"
noCap = "No cap"
title = "Add your payment method"
[payg.upgrade.footer]
capHint = "You can change your cap any time later."
checkoutHint = "Card details handled by Stripe — never touched by Stirling."
confirmHint = "Your wallet will refresh automatically in a moment."
[payg.upgrade.help]
aiBody = "summarise, classify, redact, AI-OCR"
aiTitle = "AI tools"
apiBody = "programmatic access to any Stirling endpoint"
apiTitle = "API calls"
automationBody = "chained tools or scheduled runs that don't need clicks"
automationTitle = "Automation pipelines"
footnote = "Manual tools — viewing, editing, merging, splitting, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click."
title = "What we count toward billing"
[payg.upgrade.promise]
body = "You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge."
highlight = "Manual tools stay free, always."
[payg.upgrade.steps]
cap = "Set monthly ceiling"
payment = "Add payment method"
[payg.upgrade.title]
confirm = "You're subscribed"
default = "Upgrade to Processor plan"
[payg.usage]
credit = "{{amount}} account credit"
docs = "documents"
resetsIn = "Resets in {{days}} days"
resetsTomorrow = "Resets tomorrow"
spent = "{{spend}} of {{cap}} used"
thisPeriod = "This billing period"
[payment]
autoClose = "This window will close automatically..."
canCloseWindow = "You can now close this window."
@@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [
"adminStorageSharing",
"adminMcp",
"help",
"payg",
] as const;
// Derive the type from the array
@@ -20,6 +20,7 @@ export interface AppConfig {
enableDesktopInstallSlide?: boolean;
premiumEnabled?: boolean;
premiumKey?: string;
paygEnabled?: boolean;
termsAndConditions?: string;
privacyPolicy?: string;
cookiePolicy?: string;
+2
View File
@@ -13,6 +13,7 @@ import AuthCallback from "@app/routes/AuthCallback";
import ResetPassword from "@app/routes/ResetPassword";
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
// Import global styles
import "@app/styles/tailwind.css";
@@ -36,6 +37,7 @@ export default function App() {
<AppLayout>
<OnboardingBootstrap />
<TrialExpiredBootstrap />
<SignupRequiredBootstrap />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
+11 -68
View File
@@ -18,11 +18,8 @@ import {
CreditSummary,
SubscriptionInfo,
CreditCheckResult,
ApiCredits,
} from "@app/types/credits";
import apiClient, {
setGlobalCreditUpdateCallback,
} from "@app/services/apiClient";
import { setGlobalCreditUpdateCallback } from "@app/services/apiClient";
import { synchronizeUserUpgrade } from "@app/services/userService";
import {
syncOAuthAvatar,
@@ -145,72 +142,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [profilePictureMetadata, setProfilePictureMetadata] =
useState<ProfilePictureMetadata | null>(null);
const fetchCredits = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
if (!currentSession?.user) {
console.debug("[Auth Debug] No user session, skipping credit fetch");
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
return;
}
try {
console.debug(
"[Auth Debug] Fetching credits for user:",
currentSession.user.id,
);
// Auto-fires on session init and TOKEN_REFRESHED; a backend 401 must
// stay local rather than trigger the global login redirect.
const response = await apiClient.get<ApiCredits>("/api/v1/credits", {
suppressErrorToast: true,
skipAuthRedirect: true,
});
const apiCredits = response.data;
// Map server payload to app CreditSummary
const credits: CreditSummary = {
currentCredits: apiCredits.totalAvailableCredits,
maxCredits:
apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
creditsUsed:
apiCredits.weeklyCreditsAllocated -
apiCredits.weeklyCreditsRemaining +
(apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
creditsRemaining: apiCredits.totalAvailableCredits,
resetDate: apiCredits.weeklyResetDate,
weeklyAllowance: apiCredits.weeklyCreditsAllocated,
};
setCreditSummary(credits);
setCreditBalance(credits.creditsRemaining);
const subscriptionInfo: SubscriptionInfo = {
status: "active",
tier: (credits.weeklyAllowance || 0) > 100 ? "premium" : "free",
creditsPerWeek: credits.weeklyAllowance,
maxCredits: credits.maxCredits,
};
setSubscription(subscriptionInfo);
console.debug("[Auth Debug] Credits fetched successfully:", credits);
} catch (error: unknown) {
console.debug("[Auth Debug] Failed to fetch credits:", error);
// Don't set error state for credit fetching failures to avoid disrupting auth flow
// Credits might not be available in all deployments
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
}
},
[session],
);
// Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via
// useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing
// consumers of useAuth() that still destructure creditBalance / refreshCredits
// compile cleanly; values just stay null forever and refreshCredits is a noop.
// _ underscore on the param keeps the public signature stable for callers.
const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => {
/* legacy credit fetch removed — see comment above */
}, []);
const refreshCredits = useCallback(async () => {
await fetchCredits();
}, [fetchCredits]);
/* legacy credit refresh removed — useWallet() replaces this */
}, []);
const fetchProStatus = useCallback(
async (sessionToUse?: Session | null) => {
@@ -0,0 +1,129 @@
import { useEffect, useState, useMemo } from "react";
import { Modal, Stack, Button, Text } from "@mantine/core";
import PersonAddIcon from "@mui/icons-material/PersonAdd";
import { useTranslation } from "react-i18next";
import { withBasePath } from "@app/constants/app";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import type { PaygSignupRequiredDetail } from "@app/services/paygErrorInterceptor";
/**
* Bootstrap that listens for {@code payg:signupRequired} (dispatched by
* the {@code apiClient} response interceptor when an anonymous user hits
* a billable endpoint and the server returns {@code 401 SIGNUP_REQUIRED})
* and opens a Mantine modal explaining the free 500-op/month allowance
* with a "Sign up free" CTA.
*
* <h2>Why an event bus instead of direct render</h2>
* The {@code apiClient} module is created at app boot, outside the React
* tree, and can't import JSX. We bridge with a {@code CustomEvent}: the
* interceptor dispatches, this bootstrap (mounted near the app root)
* listens and renders. Same pattern as {@code TrialExpiredBootstrap},
* just driven by a request-side trigger rather than auth state.
*
* <h2>De-duping</h2>
* If the user fires multiple billable requests in quick succession (e.g.
* clicking a tool button twice), only one modal opens — the listener
* ignores the event when the modal is already visible. The modal closes
* on backdrop click or Escape; we don't gate it on a localStorage flag
* because this is a deterministic "you need an account" UI, not a one-
* time onboarding nudge.
*/
export default function SignupRequiredBootstrap() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [category, setCategory] = useState<string | null>(null);
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent<PaygSignupRequiredDetail>).detail;
// De-dupe: if the modal is already open, the existing copy wins.
// The user is already being prompted; piling another open call on
// top would just flicker the same content.
setOpened((wasOpen) => {
if (!wasOpen) {
setCategory(detail?.category ?? null);
}
return true;
});
};
window.addEventListener("payg:signupRequired", handler as EventListener);
return () =>
window.removeEventListener(
"payg:signupRequired",
handler as EventListener,
);
}, []);
// Map the server's gate categories to user-facing nouns. The server
// returns the FeatureGate name (AI, AUTOMATION, API); the user has
// no idea what those are in raw form, so we pretty-print here. The
// fallback "this feature" keeps the modal sensible if the BE adds
// a category we don't know about.
const categoryNoun = useMemo(() => {
switch ((category ?? "").toUpperCase()) {
case "AI":
return t("payg.signupRequired.category.ai", "AI features");
case "AUTOMATION":
return t("payg.signupRequired.category.automation", "automations");
case "API":
return t("payg.signupRequired.category.api", "this tool");
default:
return t("payg.signupRequired.category.default", "this feature");
}
}, [category, t]);
const handleSignUp = () => {
window.location.href = withBasePath("/signup");
};
return (
<Modal
opened={opened}
onClose={() => setOpened(false)}
withCloseButton
centered
size="md"
radius="lg"
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
title={
<Text fw={700} size="lg">
{t("payg.signupRequired.title", "Sign up to use {{category}}", {
category: categoryNoun,
})}
</Text>
}
>
<Stack gap="md">
<Text>
{t(
"payg.signupRequired.body",
"Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.",
)}
</Text>
<Text size="sm" c="dimmed">
{t(
"payg.signupRequired.subtext",
"Creating an account is free and takes a few seconds. No credit card required.",
)}
</Text>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
}}
>
<Button variant="default" onClick={() => setOpened(false)}>
{t("payg.signupRequired.cancel", "Not now")}
</Button>
<Button
leftSection={<PersonAddIcon style={{ fontSize: 16 }} />}
onClick={handleSignUp}
>
{t("payg.signupRequired.cta", "Sign up free")}
</Button>
</div>
</Stack>
</Modal>
);
}
@@ -61,6 +61,24 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
window.removeEventListener("appConfig:notice", handler as EventListener);
}, []);
// Full-screen overlays that live inside our React tree (e.g. the PAYG
// UpgradeModal, portal'd to document.body) announce themselves here so we
// can hide — not unmount — while they're up. Unmounting would kill the
// overlay itself since it's our descendant; hiding keeps all section state
// (active tab, scroll, wallet data) intact for when the overlay closes.
const [overlayActive, setOverlayActive] = useState(false);
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as
| { open?: boolean }
| undefined;
setOverlayActive(Boolean(detail?.open));
};
window.addEventListener("appConfig:overlay", handler as EventListener);
return () =>
window.removeEventListener("appConfig:overlay", handler as EventListener);
}, []);
// When the modal opens to Plan, proactively refresh credits and log values
useEffect(() => {
if (!opened) return;
@@ -106,7 +124,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
// Left navigation structure and icons
// Left navigation structure and icons. The Plan tab now internally branches
// free vs subscribed × leader vs member via useWallet(), so the modal no
// longer plumbs paygEnabled / isLeader through to the nav builder.
const configNavSections = useMemo(
() =>
createSaasConfigNavSections(Overview, openLogoutConfirm, {
@@ -139,7 +159,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
size={isMobile ? "100%" : 1200}
centered
radius="lg"
withCloseButton={false}
@@ -147,6 +167,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
// Hidden (not closed) while a child overlay like the PAYG UpgradeModal
// is up — see the appConfig:overlay listener above. The focus trap and
// escape/outside-close must release too: the trap would steal focus
// from the Stripe card iframe, and Escape would close US underneath
// the overlay — unmounting the checkout mid-payment.
styles={{
root: { display: overlayActive ? "none" : undefined },
}}
trapFocus={!overlayActive}
closeOnEscape={!overlayActive}
closeOnClickOutside={!overlayActive}
>
<div className="modal-container">
{/* Left navigation */}
@@ -42,10 +42,7 @@ export function ManageBillingButton({
url: string;
error?: string;
}>("manage-billing", {
body: {
name: "Functions",
return_url: returnUrl,
},
body: { return_url: returnUrl },
});
if (error) throw error;
if (!data || "error" in data)
@@ -0,0 +1,624 @@
/* Pay-as-you-go settings screen — visual polish.
Uses the app's semantic theme tokens (theme.css) so it tracks light/dark. */
.payg {
--payg-accent: #0a8bff;
--payg-accent-soft: rgba(10, 139, 255, 0.1);
--payg-radius: 14px;
--payg-gap: 20px;
/* Cards sit ON the modal content bg; in light that's white-on-white so we
lean on border + shadow. Tokens are overridden per scheme below. */
--payg-card-bg: var(--bg-surface);
--payg-card-border: var(--border-default);
--payg-inset-bg: var(--bg-raised);
--payg-divider: var(--border-subtle);
}
/* Dark mode: the modal content bg is #2a2f36 and so is --bg-surface, so plain
cards vanish. Lift cards a shade above the modal and strengthen borders. */
[data-mantine-color-scheme="dark"] .payg {
--payg-card-bg: #313842;
--payg-card-border: #3d444e;
--payg-inset-bg: #272c33;
--payg-accent-soft: rgba(10, 139, 255, 0.16);
--payg-divider: #3d444e;
}
/* ── Header ─────────────────────────────────────────────────────────── */
.payg-header__title {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.payg-header__subtitle {
margin-top: 4px;
font-size: 0.875rem;
color: var(--text-muted);
}
.payg-role-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.payg-role-pill[data-leader="true"] {
background: var(--payg-accent-soft);
color: var(--payg-accent);
border: 1px solid rgba(10, 139, 255, 0.25);
}
.payg-role-pill[data-leader="false"] {
background: var(--bg-muted);
color: var(--text-muted);
border: 1px solid var(--border-default);
}
/* ── Plan header: free-vs-metered split (shared by free + subscribed) ──── */
.payg-planhead {
padding: 14px 20px;
border-radius: var(--payg-radius);
background: var(--payg-card-bg);
border: 1px solid var(--payg-card-border);
}
.payg-planhead__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.payg-planhead__eyebrow {
font-size: 0.78rem;
color: var(--text-muted);
}
.payg-planhead__split {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
}
.payg-planhead__col {
padding-right: 22px;
}
.payg-planhead__col--meter {
padding-right: 0;
padding-left: 22px;
border-left: 1px solid var(--payg-divider);
}
.payg-planhead__lbl {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 8px;
}
.payg-planhead__lbl--free {
color: #10b981;
}
.payg-planhead__lbl--meter {
color: var(--payg-accent);
}
.payg-planhead__lbl-icon {
font-size: 1rem !important;
}
.payg-planhead__title {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
line-height: 1.25;
}
.payg-planhead__body {
margin: 5px 0 0;
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
}
@media (max-width: 640px) {
.payg-planhead__split {
grid-template-columns: 1fr;
gap: 16px;
}
.payg-planhead__col {
padding-right: 0;
}
.payg-planhead__col--meter {
padding-left: 0;
padding-top: 16px;
border-left: none;
border-top: 1px solid var(--payg-divider);
}
}
/* ── Generic card ───────────────────────────────────────────────────── */
.payg-card {
background: var(--payg-card-bg);
border: 1px solid var(--payg-card-border);
border-radius: var(--payg-radius);
padding: 16px 20px;
box-shadow: var(--shadow-xs);
}
.payg-card__title {
font-size: 0.95rem;
font-weight: 650;
color: var(--text-primary);
}
.payg-card__subtitle {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Hero usage panel ───────────────────────────────────────────────── */
.payg-hero {
position: relative;
overflow: hidden;
border-radius: var(--payg-radius);
padding: 18px 22px;
border: 1px solid var(--payg-card-border);
background: var(--payg-card-bg);
box-shadow: var(--shadow-xs);
}
.payg-hero::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.9;
}
.payg-hero[data-state="FULL"]::before {
background: linear-gradient(
135deg,
rgba(10, 139, 255, 0.12) 0%,
rgba(10, 139, 255, 0) 55%
);
}
.payg-hero[data-state="WARNED"]::before {
background: linear-gradient(
135deg,
rgba(234, 179, 8, 0.16) 0%,
rgba(234, 179, 8, 0) 55%
);
}
.payg-hero[data-state="DEGRADED"]::before {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.16) 0%,
rgba(239, 68, 68, 0) 55%
);
}
.payg-hero__inner {
position: relative;
z-index: 1;
}
.payg-hero__eyebrow {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.payg-hero__figure {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 6px;
}
.payg-hero__spend {
font-size: 2.35rem;
font-weight: 750;
line-height: 1;
letter-spacing: -0.03em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.payg-hero__cap {
font-size: 1rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.payg-hero__meta {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.payg-hero__meta-dot {
color: var(--border-strong);
}
.payg-hero__credit {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-green-700);
background: rgba(34, 197, 94, 0.14);
}
[data-mantine-color-scheme="dark"] .payg-hero__credit {
color: #4ade80;
background: rgba(34, 197, 94, 0.18);
}
/* Make Mantine default-variant buttons read clearly on the lifted cards in
both themes (their stock border can wash out against --payg-card-bg). */
.payg .mantine-Button-root[data-variant="default"] {
border-color: var(--payg-card-border);
}
/* Dark mode: the stock default button is a flat slab that disappears into the
card. Lift it with a soft top-down gradient + brighter border, and warm the
hover so the buttons feel tactile against --payg-card-bg (#313842). Driven
through Mantine's --button-* vars. The same rule covers disabled buttons
(Cancel/Update cap before the form is dirty) so they read identically to the
always-enabled ones — Mantine only fades them via opacity. */
[data-mantine-color-scheme="dark"]
.payg
.mantine-Button-root[data-variant="default"] {
--button-bg: linear-gradient(180deg, #3d4651 0%, #353d48 100%);
--button-hover: linear-gradient(180deg, #475160 0%, #3d4654 100%);
--button-bd: 1px solid #4b5563;
--button-color: var(--mantine-color-white);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 1px 2px rgba(0, 0, 0, 0.25);
}
/* Mantine recolours disabled buttons to a dim grey via a hard-coded color/bg
on the disabled selector, overriding --button-color. Re-assert white + the
gradient so disabled buttons match the rest. */
[data-mantine-color-scheme="dark"]
.payg
.mantine-Button-root[data-variant="default"]:disabled,
[data-mantine-color-scheme="dark"]
.payg
.mantine-Button-root[data-variant="default"][data-disabled] {
background: var(--button-bg);
color: var(--mantine-color-white);
}
/* Status chip */
.payg-status {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 13px;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.payg-status__dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.payg-status[data-state="FULL"] {
background: var(--color-green-100);
color: var(--color-green-700);
}
.payg-status[data-state="FULL"] .payg-status__dot {
background: var(--color-green-500);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
}
.payg-status[data-state="WARNED"] {
background: var(--color-yellow-100);
color: var(--color-yellow-700);
}
.payg-status[data-state="WARNED"] .payg-status__dot {
background: var(--color-yellow-500);
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
}
.payg-status[data-state="DEGRADED"] {
background: var(--color-red-100);
color: var(--color-red-700);
}
.payg-status[data-state="DEGRADED"] .payg-status__dot {
background: var(--color-red-500);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
}
/* Segmented usage bar */
.payg-bar {
margin-top: 18px;
height: 10px;
border-radius: 999px;
background: var(--bg-muted);
overflow: hidden;
position: relative;
}
.payg-bar__fill {
height: 100%;
border-radius: 999px;
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.payg-bar__fill[data-state="FULL"] {
background: linear-gradient(90deg, #0a8bff, #38bdf8);
}
.payg-bar__fill[data-state="WARNED"] {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.payg-bar__fill[data-state="DEGRADED"] {
background: linear-gradient(90deg, #dc2626, #f87171);
}
/* ── "What counts as a document?" expandable help ───────────────────── */
.payg-help {
margin-top: 12px;
}
.payg-help__toggle {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
.payg-help__toggle:hover {
color: var(--text-primary);
}
.payg-help__chevron {
transition: transform 0.15s ease;
}
.payg-help__toggle[aria-expanded="true"] .payg-help__chevron {
transform: rotate(180deg);
}
.payg-help__panel {
margin-top: 10px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--payg-card-border);
background: var(--bg-muted);
font-size: 0.8125rem;
line-height: 1.45;
color: var(--text-secondary);
}
.payg-help__panel ul {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
}
/* ── Cap preview strip ──────────────────────────────────────────────── */
.payg-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 16px;
border-radius: 10px;
background: var(--payg-accent-soft);
border: 1px solid rgba(10, 139, 255, 0.2);
}
.payg-preview__icon {
color: var(--payg-accent);
display: flex;
}
.payg-preview__main {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 550;
}
.payg-preview__note {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
/* ── Gates grid ─────────────────────────────────────────────────────── */
.payg-gates {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 4px;
}
@media (max-width: 720px) {
.payg-gates {
grid-template-columns: 1fr;
}
}
.payg-gate {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 15px;
border-radius: 11px;
border: 1px solid var(--payg-card-border);
background: var(--payg-inset-bg);
}
.payg-gate[data-enabled="false"] {
border-style: dashed;
opacity: 0.85;
}
.payg-gate__chip {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9px;
flex-shrink: 0;
}
.payg-gate[data-enabled="true"] .payg-gate__chip {
background: rgba(34, 197, 94, 0.16);
color: #22c55e;
}
.payg-gate[data-enabled="false"] .payg-gate__chip {
background: rgba(239, 68, 68, 0.16);
color: #f87171;
}
.payg-gate__label {
font-size: 0.8125rem;
line-height: 1.35;
color: var(--text-primary);
flex: 1;
}
.payg-gate__tag {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
white-space: nowrap;
}
.payg-gate__tag[data-variant="on"] {
color: var(--text-muted);
background: var(--bg-muted);
}
.payg-gate__tag[data-variant="pause"] {
color: #dc2626;
background: rgba(239, 68, 68, 0.12);
}
[data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] {
color: #f87171;
background: rgba(239, 68, 68, 0.18);
}
/* ── Member rows ────────────────────────────────────────────────────── */
.payg-member {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 4px;
border-bottom: 1px solid var(--payg-divider);
}
.payg-member:last-child {
border-bottom: none;
}
.payg-member__avatar {
width: 34px;
height: 34px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 650;
color: #fff;
flex-shrink: 0;
}
.payg-member__name {
font-size: 0.875rem;
font-weight: 550;
color: var(--text-primary);
}
.payg-member__email {
font-size: 0.75rem;
color: var(--text-muted);
}
.payg-member__usage {
text-align: right;
min-width: 120px;
}
.payg-member__usage-num {
font-size: 0.8125rem;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.payg-member__minibar {
height: 5px;
width: 90px;
border-radius: 999px;
background: var(--payg-inset-bg);
overflow: hidden;
margin-top: 5px;
margin-left: auto;
}
.payg-member__minibar-fill {
height: 100%;
border-radius: 999px;
background: var(--payg-accent);
}
/* ── Activity feed ──────────────────────────────────────────────────── */
.payg-activity-row {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 4px;
border-bottom: 1px solid var(--payg-divider);
}
.payg-activity-row:last-child {
border-bottom: none;
}
.payg-activity__dot {
width: 9px;
height: 9px;
border-radius: 999px;
flex-shrink: 0;
}
.payg-activity__dot[data-kind="ai"] {
background: var(--category-color-formatting); /* purple */
}
.payg-activity__dot[data-kind="automation"] {
background: var(--category-color-automation); /* pink */
}
.payg-activity__label {
font-size: 0.8438rem;
color: var(--text-primary);
flex: 1;
}
.payg-activity__ts {
font-size: 0.75rem;
color: var(--text-muted);
}
.payg-activity__kind {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
background: var(--bg-muted);
padding: 2px 8px;
border-radius: 999px;
}
.payg-activity__units {
font-size: 0.8125rem;
font-weight: 650;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
min-width: 58px;
text-align: right;
}
/* ── Stripe CTA card ────────────────────────────────────────────────── */
.payg-stripe {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-radius: var(--payg-radius);
border: 1px solid rgba(99, 91, 255, 0.28);
background: linear-gradient(
135deg,
rgba(99, 91, 255, 0.1) 0%,
rgba(99, 91, 255, 0.02) 100%
);
}
.payg-stripe__title {
font-size: 0.9375rem;
font-weight: 650;
color: var(--text-primary);
}
.payg-stripe__subtitle {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 2px;
}
@@ -0,0 +1,788 @@
/**
* Pay-as-you-go billing & usage section — the SUBSCRIBED leader/member views.
*
* All data comes from the real {@code Wallet} snapshot ({@code GET
* /api/v1/payg/wallet} via {@link useWallet}); there is no mock fallback. What
* the wallet doesn't provide, the UI doesn't show:
*
* - spend + cap render in the units/USD the backend actually returns
* ({@code spendUnitsThisPeriod}, {@code capUsd}, {@code noCap})
* - the per-category breakdown comes from {@code categoryBreakdown}
* (wallet_category_summary view)
* - money-equivalent display and the units↔money cap preview need
* stripe.prices via Sync Engine (design §13 / PR-C2) and are deliberately
* absent until that ships
* - the activity feed renders {@code wallet.recent}, which is {@code []} in
* V1 — so it shows a real empty state, not fabricated rows
*/
import React, { useState } from "react";
import { Button, Group, Stack, Text } from "@mantine/core";
import { useRenderCount } from "@app/hooks/useRenderCount";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import LockIcon from "@mui/icons-material/LockOutlined";
import CheckIcon from "@mui/icons-material/CheckRounded";
import HelpOutlineIcon from "@mui/icons-material/HelpOutlineRounded";
import ExpandMoreIcon from "@mui/icons-material/ExpandMoreRounded";
import BoltIcon from "@mui/icons-material/BoltRounded";
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
import { alert as showToast } from "@app/components/toast";
// Relative (not @app/*) so the co-located CSS + sibling component resolve directly.
// eslint-disable-next-line no-restricted-imports
import "./Payg.css";
// eslint-disable-next-line no-restricted-imports
import SpendCapControl from "./SpendCapControl";
import { useTranslation } from "react-i18next";
import type { Wallet } from "@app/hooks/useWallet";
// ─── Types ────────────────────────────────────────────────────────────────
type Gate = "OFFSITE_PROCESSING" | "AUTOMATION" | "AI_SUPPORT" | "CLIENT_SIDE";
interface PaygProps {
role: "LEADER" | "MEMBER";
/** Real wallet snapshot from {@code useWallet}. Single source of truth. */
wallet: Wallet;
/**
* Persist a cap change. Provided by {@code Plan} → {@code useWallet} for the
* leader view; absent on the member view (read-only).
*/
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
/**
* Open the Stripe Customer Portal. When omitted the Stripe card is hidden.
* On error the implementation shows a friendly toast and resolves — callers
* don't need to wrap in try/catch.
*/
onOpenPortal?: () => Promise<void>;
}
// ─── Helpers ──────────────────────────────────────────────────────────────
// Stable-ish avatar colour from a string.
const AVATAR_COLORS = [
"#0a8bff",
"#8b5cf6",
"#ec4899",
"#10b981",
"#f59e0b",
"#06b6d4",
];
function avatarColor(seed: string): string {
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
}
function gateLabel(
g: Gate,
t: (k: string, fallback: string) => string,
): string {
switch (g) {
case "OFFSITE_PROCESSING":
return t(
"payg.gates.offsite",
"Server tools (compress, OCR, convert, watermark…)",
);
case "AUTOMATION":
return t("payg.gates.automation", "Automations & pipelines");
case "AI_SUPPORT":
return t("payg.gates.ai", "AI tools (AI Create, suggestions, AI-OCR)");
case "CLIENT_SIDE":
return t(
"payg.gates.client",
"Browser-only tools (viewer, page editor, file management)",
);
}
}
// ─── "What counts as a document?" help ──────────────────────────────────────
/**
* Expandable explainer for the billing unit. Shared by the subscribed hero
* here and the free-tier hero in {@code PaygFree.tsx}. The bullets state the
* real charge mechanics (DefaultDocumentClassifier + JobChargeService)
* without hardcoding the policy-tunable thresholds: each non-empty file is
* at least one document; page count / file size can make it more; chained
* steps on the same file join the open process instead of re-charging; a
* first-step failure writes a compensating refund.
*/
export function DocHelp() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="payg-help">
<button
type="button"
className="payg-help__toggle"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<HelpOutlineIcon sx={{ fontSize: 15 }} />
{t("payg.docHelp.toggle", "What counts as a PDF?")}
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
</button>
{open && (
<div className="payg-help__panel">
<ul>
<li>
{t(
"payg.docHelp.billable",
"Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free.",
)}
</li>
<li>
{t(
"payg.docHelp.perFile",
"Each file you process counts as one PDF. Very long or very large files can count as more than one.",
)}
</li>
<li>
{t(
"payg.docHelp.chains",
"Running the same file through several steps of one automation counts it once, not once per step.",
)}
</li>
<li>
{t(
"payg.docHelp.refunds",
"If a job fails on its first step, the PDF is credited back automatically.",
)}
</li>
</ul>
</div>
)}
</div>
);
}
// ─── Hero usage panel ───────────────────────────────────────────────────────
/**
* Format minor units of an ISO currency for display ("$2.24", "£0.40"). Only
* called when the backend resolved the rate — currency is always present
* alongside a non-null money amount.
*/
function formatMinor(minor: number, currency: string | null): string {
const code = (currency ?? "usd").toUpperCase();
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
}).format(minor / 100);
} catch {
return `${(minor / 100).toFixed(2)} ${code}`;
}
}
/** Currency symbol for compact inline use; falls back to the ISO code. */
function currencySymbol(currency: string | null): string {
switch ((currency ?? "").toLowerCase()) {
case "usd":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
default:
return currency ? currency.toUpperCase() + " " : "$";
}
}
/**
* Usage hero — gradient panel with the allowance bar. Every number is real:
* {@code billableUsed} is the ledger's period sum over the team's actual
* billing window (the Stripe subscription period); {@code billableLimit} is
* the backend-derived document ceiling (free allowance + what the money cap
* buys at the subscription Price's per-document rate, null when uncapped);
* {@code estimatedBillMinor} is spend beyond the allowance at that rate.
* Fields the backend couldn't resolve are null and simply not rendered.
*/
function UsageHero({ wallet }: { wallet: Wallet }) {
const { t } = useTranslation();
const periodEnd = new Date(wallet.billingPeriodEnd);
const daysLeft = Math.max(
0,
Math.ceil((periodEnd.getTime() - Date.now()) / 86_400_000),
);
const hasCap = !wallet.noCap && wallet.capUsd != null;
const breakdown = wallet.categoryBreakdown;
const limit = wallet.billableLimit;
const hasLimit = limit != null && limit > 0;
const pct = hasLimit
? Math.min(100, (wallet.billableUsed / limit) * 100)
: 0;
const state = hasLimit
? pct >= 100
? "DEGRADED"
: pct >= 80
? "WARNED"
: "FULL"
: "FULL";
const stateLabel = {
FULL: t("payg.state.full", "Healthy"),
WARNED: t("payg.state.warned", "Approaching cap"),
DEGRADED: t("payg.state.degraded", "Cap reached"),
}[state];
return (
<div className="payg-hero" data-state={state}>
<div className="payg-hero__inner">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<div className="payg-hero__eyebrow">
{t("payg.usage.thisPeriod", "This billing period")}
</div>
<div className="payg-hero__figure">
<span className="payg-hero__spend">
{wallet.billableUsed.toLocaleString()}
</span>
<span className="payg-hero__cap">
{hasLimit
? t(
"payg.usage.ofLimitProcessed",
"/ {{limit}} PDFs processed",
{ limit: limit.toLocaleString() },
)
: t("payg.usage.processed", "PDFs processed")}
</span>
</div>
</div>
<div className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</div>
</Group>
{hasLimit && (
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
)}
<div className="payg-hero__meta">
<span>
{t("payg.usage.firstFree", "First {{free}} free", {
free: wallet.freeAllowance.toLocaleString(),
})}
</span>
{wallet.estimatedBillMinor != null && (
<>
<span className="payg-hero__meta-dot"></span>
<span>
{t("payg.usage.estBill", "≈ {{amount}} so far this period", {
amount: formatMinor(
wallet.estimatedBillMinor,
wallet.currency,
),
})}
</span>
</>
)}
<span className="payg-hero__meta-dot"></span>
<span>
{hasCap
? t("payg.usage.capLine", "{{cap}}/mo cap", {
cap: `${currencySymbol(wallet.currency)}${wallet.capUsd}`,
})
: t("payg.usage.noCap", "No monthly cap")}
</span>
<span className="payg-hero__meta-dot"></span>
<span>
{daysLeft === 1
? t("payg.usage.resetsTomorrow", "Resets tomorrow")
: t("payg.usage.resetsIn", "Resets in {{days}} days", {
days: daysLeft,
})}
</span>
</div>
<div className="payg-hero__meta" style={{ marginTop: 6 }}>
<span>
{t(
"payg.usage.breakdown",
"AI {{ai}} • Automation {{automation}} • API {{api}}",
{
ai: breakdown.ai.toLocaleString(),
automation: breakdown.automation.toLocaleString(),
api: breakdown.api.toLocaleString(),
},
)}
</span>
</div>
<DocHelp />
</div>
</div>
);
}
// ─── Cap editor ─────────────────────────────────────────────────────────────
interface CapEditorProps {
/** Current cap in major currency units; null = no cap set. */
capUsd: number | null;
/** True when the leader explicitly disabled the cap. */
noCap: boolean;
/** Per-document rate in minor units; null when unknown — preview hides. */
pricePerDocMinor: number | null;
/** Currency of the rate; pairs with {@link CapEditorProps#pricePerDocMinor}. */
currency: string | null;
/**
* Persist the cap change. Receives whole major units (matches the backend's
* {@code PATCH /api/v1/payg/cap} body) or null for no-cap.
*/
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
}
/**
* Single-row cap editor: the shared {@link SpendCapControl} (preset chips +
* inline custom-entry pill + no-cap + inline Save) over a live "≈ N paid
* PDFs/month" estimate, wrapped in the plan-page card chrome + the cap-reached
* disclosure. Save-only — the working value is local, so abandoning the card
* abandons the edit. The very same control drives the upgrade checkout flow.
*/
function CapEditor({
capUsd,
noCap,
pricePerDocMinor,
currency,
onSaveCap,
}: CapEditorProps) {
const { t } = useTranslation();
const savedCap = noCap || capUsd == null ? null : capUsd;
const [working, setWorking] = useState<number | null>(savedCap);
return (
<div className="payg-card">
<Stack gap="sm">
<div>
<div className="payg-card__title">
{t("payg.cap.title", "Monthly spending cap")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.cap.subtitle",
"The most your team can spend per month. Billable processing pauses at the cap and resumes next period.",
)}
</div>
</div>
<SpendCapControl
capUsd={working}
onChange={setWorking}
pricePerDocMinor={pricePerDocMinor}
currency={currency}
savedCapUsd={savedCap}
onSave={onSaveCap}
/>
<CapReachedHelp />
</Stack>
</div>
);
}
function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) {
const { t } = useTranslation();
const hasCap = !noCap && capUsd != null;
return (
<div className="payg-card">
<Stack gap="sm">
<div className="payg-card__title">
{t("payg.cap.title", "Monthly spending cap")}
</div>
<Group gap="xs" align="baseline">
<Text fz={28} fw={750} lh={1}>
{hasCap ? `$${capUsd}` : t("payg.cap.noneShort", "No cap")}
</Text>
{hasCap && (
<Text c="dimmed">{t("payg.cap.perMonth", "/ month")}</Text>
)}
</Group>
<div className="payg-preview" style={{ marginTop: 6 }}>
<div>
<div className="payg-preview__main">
{t(
"payg.member.askLeader",
"Only your team owner can change the cap.",
)}
</div>
</div>
</div>
<CapReachedHelp />
</Stack>
</div>
);
}
// ─── Gates ────────────────────────────────────────────────────────────────
// What still works once the cap is hit. Everyday tools keep running; only AI
// and automation/pipelines pause until the cap resets or is raised.
const GATE_CAP_BEHAVIOR: { gate: Gate; staysAtCap: boolean }[] = [
{ gate: "CLIENT_SIDE", staysAtCap: true },
{ gate: "OFFSITE_PROCESSING", staysAtCap: true },
{ gate: "AUTOMATION", staysAtCap: false },
{ gate: "AI_SUPPORT", staysAtCap: false },
];
/**
* Folded-in "what happens at the cap" — previously its own GatesCard, now a
* collapsed disclosure rendered inside the cap card(s) to save vertical space.
* Mirrors the {@link DocHelp} toggle pattern; default-collapsed so it costs no
* height until the user opens it.
*/
function CapReachedHelp() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="payg-help">
<button
type="button"
className="payg-help__toggle"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<HelpOutlineIcon sx={{ fontSize: 15 }} />
{t("payg.gates.title", "What happens when the cap is reached")}
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
</button>
{open && (
<div className="payg-help__panel">
<div className="payg-gates">
{GATE_CAP_BEHAVIOR.map(({ gate, staysAtCap }) => (
<div className="payg-gate" data-enabled={staysAtCap} key={gate}>
<span className="payg-gate__chip">
{staysAtCap ? (
<CheckIcon sx={{ fontSize: 18 }} />
) : (
<LockIcon sx={{ fontSize: 16 }} />
)}
</span>
<span className="payg-gate__label">{gateLabel(gate, t)}</span>
{!staysAtCap && (
<span className="payg-gate__tag" data-variant="pause">
{t("payg.gates.pauses", "pauses at cap")}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
// ─── Per-member usage ────────────────────────────────────────────────────────
/**
* Leader-only roster of each teammate's billable usage this period. Display-only — per-member
* sub-cap enforcement isn't shipped (see the follow-ups note), so there's no cap control here.
*/
function MemberUsage({ members }: { members: Wallet["members"] }) {
const { t } = useTranslation();
return (
<div className="payg-card">
<Stack gap="sm">
<div>
<div className="payg-card__title">
{t("payg.members.title", "Team member usage")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.members.subtitle",
"Billable PDFs each teammate has processed this period.",
)}
</div>
</div>
<div>
{members.map((m) => (
<div className="payg-member" key={m.userId}>
<span
className="payg-member__avatar"
style={{ background: avatarColor(m.userId) }}
>
{m.name.charAt(0).toUpperCase()}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="payg-member__name">{m.name}</div>
<div className="payg-member__email">{m.email}</div>
</div>
<div className="payg-member__usage">
<div className="payg-member__usage-num">
{m.spendUnits.toLocaleString()}{" "}
<Text span size="xs" c="dimmed">
{t("payg.members.docs", "PDFs")}
</Text>
</div>
</div>
</div>
))}
</div>
</Stack>
</div>
);
}
// ─── Activity feed ──────────────────────────────────────────────────────────
/**
* Feature flag — the activity feed is hidden until the meter-event surface is
* built and polished (Wave 2). The backend returns {@code []} today, so an
* unpolished "No billable activity yet" card adds nothing. Flip to {@code true}
* once {@code wallet.recent} carries real rows. Kept as a flag (not deleted) so
* the renderer below stays wired and ready.
*/
const SHOW_ACTIVITY_FEED = false;
/**
* Renders {@code wallet.recent} — the backend returns {@code []} in V1 (the
* meter-event surface ships in Wave 2), so today this shows a real empty
* state. The row renderer is ready for when the rows arrive; fields are read
* defensively because the activity-row shape isn't finalised yet (the Wallet
* type carries {@code Record<string, unknown>} for the same reason).
*/
function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
const { t } = useTranslation();
return (
<div className="payg-card">
<Stack gap="sm">
<div>
<div className="payg-card__title">
{t("payg.activity.title", "Recent billable activity")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.activity.subtitle",
"Only AI and automation draw from your budget. Everyday tools are free and aren't listed here.",
)}
</div>
</div>
{recent.length === 0 ? (
<Text size="sm" c="dimmed">
{t(
"payg.activity.empty",
"No billable activity yet this period.",
)}
</Text>
) : (
<div>
{recent.map((r, i) => (
<div className="payg-activity-row" key={String(r.id ?? i)}>
<span
className="payg-activity__dot"
data-kind={String(r.kind ?? "")}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="payg-activity__label">
{String(r.label ?? "")}
</div>
<div className="payg-activity__ts">{String(r.ts ?? "")}</div>
</div>
<span className="payg-activity__kind">
{String(r.kind ?? "")}
</span>
<span className="payg-activity__units">
{String(r.docUnits ?? 0)} {t("payg.activity.docs", "docs")}
</span>
</div>
))}
</div>
)}
</Stack>
</div>
);
}
// ─── Stripe CTA ──────────────────────────────────────────────────────────────
function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise<void> }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
try {
await onOpenPortal();
} catch (e: unknown) {
// 503 = Supabase edge fn isn't configured (local dev without
// PORTAL_NOT_CONFIGURED env). 404 = no Stripe customer yet (e.g. the
// team was force-subscribed via dev hooks). Both are user-actionable
// in roughly the same way ("try again later or contact support") so
// we don't bother branching the copy.
console.warn("[Payg] portal session failed", e);
showToast({
alertType: "warning",
title: t(
"payg.stripe.toast.unavailable.title",
"Billing portal unavailable",
),
body: t(
"payg.stripe.toast.unavailable.body",
"Billing portal isn't available right now. Try again in a moment.",
),
location: "bottom-right",
});
} finally {
setLoading(false);
}
};
return (
<div className="payg-stripe">
<div>
<div className="payg-stripe__title">
{t("payg.stripe.title", "Manage billing in Stripe")}
</div>
<div className="payg-stripe__subtitle">
{t(
"payg.stripe.subtitle",
"Receipts, invoices, payment method, billing currency.",
)}
</div>
</div>
<Button
onClick={handleClick}
loading={loading}
rightSection={<OpenInNewIcon sx={{ fontSize: 16 }} />}
variant="light"
>
{t("payg.stripe.open", "Open billing portal")}
</Button>
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────
const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) => {
useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember");
const { t } = useTranslation();
const isLeader = role === "LEADER";
const fmt = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
return (
<div className="payg">
<Stack gap="md">
{/* The modal chrome already renders the section title ("Billing &
usage"), so we lead with the descriptive subtitle + role pill. */}
<div className="payg-planhead">
<div className="payg-planhead__top">
<span className="payg-planhead__eyebrow">
{t("payg.header.eyebrow", "Processor plan · {{start}} {{end}}", {
start: fmt(wallet.billingPeriodStart),
end: fmt(wallet.billingPeriodEnd),
})}
</span>
<span className="payg-role-pill" data-leader={isLeader}>
{isLeader
? t("payg.role.leader", "Team owner")
: t("payg.role.member", "Member")}
</span>
</div>
<div className="payg-planhead__split">
<div className="payg-planhead__col">
<div className="payg-planhead__lbl payg-planhead__lbl--free">
<AllInclusiveIcon
className="payg-planhead__lbl-icon"
fontSize="small"
/>
{t("payg.header.freeLabel", "Always free")}
</div>
<p className="payg-planhead__title">
{t("payg.header.freeTitle", "Unlimited PDF editing")}
</p>
<p className="payg-planhead__body">
{t(
"payg.header.freeBody",
"View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.",
)}
</p>
</div>
<div className="payg-planhead__col payg-planhead__col--meter">
<div className="payg-planhead__lbl payg-planhead__lbl--meter">
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
{t("payg.header.meterLabel", "Metered")}
</div>
<p className="payg-planhead__title">
{t("payg.header.meterTitle", "Automation · AI · API")}
</p>
<p className="payg-planhead__body">
{t(
"payg.header.meterBody",
"{{limit}} free PDFs to start, then billed per PDF up to your cap.",
{ limit: wallet.freeAllowance.toLocaleString() },
)}
</p>
</div>
</div>
</div>
<UsageHero wallet={wallet} />
{isLeader ? (
<CapEditor
capUsd={wallet.capUsd}
noCap={wallet.noCap}
pricePerDocMinor={wallet.pricePerDocMinor}
currency={wallet.currency}
onSaveCap={onSaveCap}
/>
) : (
<CapReadOnly capUsd={wallet.capUsd} noCap={wallet.noCap} />
)}
{isLeader && wallet.members.length > 0 && (
<MemberUsage members={wallet.members} />
)}
{SHOW_ACTIVITY_FEED && <ActivityFeed recent={wallet.recent} />}
{isLeader && onOpenPortal && (
<StripePortalLink onOpenPortal={onOpenPortal} />
)}
</Stack>
</div>
);
};
export default Payg;
// Convenience exports for the config nav to render either variant directly.
export interface PaygLeaderProps {
/** See {@link PaygProps#wallet}. */
wallet: Wallet;
/** See {@link PaygProps#onSaveCap}. */
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
/** See {@link PaygProps#onOpenPortal}. */
onOpenPortal?: () => Promise<void>;
}
export const PaygLeader: React.FC<PaygLeaderProps> = ({
wallet,
onSaveCap,
onOpenPortal,
}) => (
<Payg
role="LEADER"
wallet={wallet}
onSaveCap={onSaveCap}
onOpenPortal={onOpenPortal}
/>
);
export const PaygMember: React.FC<{ wallet: Wallet }> = ({ wallet }) => (
<Payg role="MEMBER" wallet={wallet} />
);
@@ -0,0 +1,322 @@
/* Styles for the free-tier Plan views (PaygFreeLeader + PaygFreeMember).
Extends Payg.css — the hero + bar + status pill reuse those classes so the
visual reads as a continuation of the Processor dashboard. New classes here
are prefixed `paygf-`. */
.payg-hero__head-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: nowrap;
}
/* ── Editor plan card (always-free tools only, no billing window) ─────── */
/* Reuses .payg-planhead chrome; the eyebrow sits in the flex top row so its
default 8px bottom margin would misalign it against the role pill. */
.paygf-editorcard__eyebrow {
margin-bottom: 0;
}
/* ── Processor plan card: two-column (pitch + benefits | meter + CTA) ──── */
.paygf-proc {
gap: 14px;
}
.paygf-proc__eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--payg-accent);
}
.paygf-proc__split {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 20px;
}
.paygf-proc__pitch {
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.paygf-proc__pitch .paygf-cta__subtitle {
margin-top: 0;
}
/* Force the benefits into a single column inside the narrower left column. */
.paygf-proc__benefits {
grid-template-columns: 1fr;
}
.paygf-proc__aside {
display: flex;
flex-direction: column;
gap: 12px;
padding-left: 20px;
border-left: 1px solid var(--payg-divider);
}
.paygf-proc__cta {
width: 100%;
text-align: center;
}
.paygf-proc__reassure {
text-align: center;
}
.paygf-proc__membernote {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.8rem;
line-height: 1.45;
color: var(--text-muted);
}
.paygf-proc__membernote-icon {
flex-shrink: 0;
color: var(--text-muted);
font-size: 1.1rem !important;
margin-top: 1px;
}
@media (max-width: 640px) {
.paygf-proc__split {
grid-template-columns: 1fr;
}
.paygf-proc__aside {
padding-left: 0;
padding-top: 16px;
border-left: none;
border-top: 1px solid var(--payg-divider);
}
}
/* ── Compact one-time free meter (inside the Processor aside) ─────────── */
.paygf-meter {
padding: 14px 16px;
border-radius: 11px;
background: var(--payg-inset-bg);
border: 1px solid var(--payg-card-border);
}
.paygf-meter__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.paygf-meter__figure {
display: flex;
align-items: baseline;
gap: 7px;
}
.paygf-meter__num {
font-size: 1.7rem;
font-weight: 750;
line-height: 1;
letter-spacing: -0.02em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.paygf-meter__cap {
font-size: 0.85rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.paygf-meter .payg-bar {
margin-top: 11px;
}
.paygf-meter__meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
font-size: 0.78rem;
color: var(--text-secondary);
}
/* ── Leader CTA card ──────────────────────────────────────────────────── */
.paygf-cta {
padding: 18px 22px;
border-radius: var(--payg-radius);
background: var(--payg-card-bg);
/* Gradient border using padding-box / border-box trick so the inner bg
stays solid + the outline gets the brand gradient. */
border: 1.5px solid transparent;
background:
linear-gradient(var(--payg-card-bg), var(--payg-card-bg)) padding-box,
linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%) border-box;
box-shadow: 0 10px 28px -18px rgba(10, 139, 255, 0.4);
display: flex;
flex-direction: column;
gap: 16px;
}
.paygf-cta__heading-row {
display: flex;
gap: 14px;
align-items: center;
}
.paygf-cta__icon {
flex-shrink: 0;
padding: 10px;
border-radius: 12px;
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
color: white !important;
font-size: 1.7rem !important;
}
.paygf-cta__heading-text {
flex: 1 1 auto;
}
.paygf-cta__title {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
line-height: 1.25;
}
.paygf-cta__subtitle {
margin: 4px 0 0;
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.5;
}
.paygf-cta__benefits {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px 16px;
}
.paygf-cta__benefits li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.85rem;
color: var(--text-primary);
line-height: 1.45;
}
.paygf-cta__check {
flex-shrink: 0;
color: var(--payg-accent);
margin-top: 2px;
}
/* Footer row: button on the left, reassurance text on the right.
Right-side text wraps to second line below button on narrow viewports. */
.paygf-cta__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding-top: 4px;
border-top: 1px solid var(--payg-divider);
padding-top: 16px;
}
.paygf-cta__button {
padding: 13px 22px;
border: none;
border-radius: 11px;
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
color: white;
font-weight: 600;
font-size: 0.95rem;
font-family: inherit;
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease;
white-space: nowrap;
}
.paygf-cta__button:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55);
}
.paygf-cta__reassurance {
margin: 0;
font-size: 0.78rem;
color: var(--text-muted);
text-align: right;
}
/* ── Member ask-the-owner note ───────────────────────────────────────── */
.paygf-member-note {
padding: 18px 22px;
border-radius: var(--payg-radius);
background: var(--payg-inset-bg);
border: 1px solid var(--payg-card-border);
display: flex;
gap: 14px;
align-items: flex-start;
}
.paygf-member-note__icon {
flex-shrink: 0;
color: var(--text-muted);
padding: 8px;
background: var(--payg-card-bg);
border-radius: 10px;
font-size: 1.6rem !important;
}
.paygf-member-note__title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.005em;
line-height: 1.3;
}
.paygf-member-note__body {
margin: 6px 0 0;
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.5;
}
/* ── Explainer (free vs metered) — shared by leader + member ──────────── */
.paygf-explainer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: var(--payg-gap);
}
@media (max-width: 640px) {
.paygf-explainer {
grid-template-columns: 1fr;
}
}
.paygf-explainer__col {
padding: 18px 20px;
border-radius: 12px;
background: var(--payg-card-bg);
border: 1px solid var(--payg-card-border);
}
.paygf-explainer__label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 10px;
}
.paygf-explainer__icon {
font-size: 1rem !important;
}
.paygf-explainer__icon--free {
color: #10b981;
}
.paygf-explainer__icon--paid {
color: var(--payg-accent);
}
.paygf-explainer__text {
margin: 0;
font-size: 0.85rem;
color: var(--text-primary);
line-height: 1.55;
}
@@ -0,0 +1,374 @@
/**
* Free-tier Plan views (leader + member) shown before the team subscribes to
* Processor. Mirrors the visual language of {@code Payg.tsx} so the upgrade
* feels like enabling a switch, not visiting a new product.
*
* <p><b>Free tier model (set 2026-06):</b> users only pay for
* <em>automation</em>, <em>AI</em>, and <em>API</em> operations. Manual tools
* — viewing, editing, signing, merging, splitting, conversion, manual OCR,
* watermarks, compression — are unmetered, no matter where they're triggered
* from. The distinction is the <em>type of work</em> (manual tool vs
* automation / AI / API), not where the click happens, because automation and
* AI also have UI surfaces. The one-time free grant (default 500) applies
* <em>only</em> to the three billable categories — it is a lifetime allowance,
* not a monthly one, and a team keeps any unused portion after subscribing.
*
* <p>Layout: a slim <b>Editor plan</b> card (always-free tools only — no dates,
* no metered split) on top, then a single <b>Processor plan</b> card that
* two-columns the upgrade pitch + benefits (left) against the one-time free
* meter stacked over the call-to-action (right).
*
* <p>Two variants:
* - {@link PaygFreeLeader} — the right column's CTA opens the upgrade modal.
* - {@link PaygFreeMember} — read-only; the CTA is replaced with an
* ask-the-owner note.
*/
import React, { useMemo, useState } from "react";
import { Stack } from "@mantine/core";
import BoltIcon from "@mui/icons-material/BoltRounded";
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
import CheckIcon from "@mui/icons-material/CheckRounded";
import LockIcon from "@mui/icons-material/LockOutlined";
import { useTranslation } from "react-i18next";
import { useRenderCount } from "@app/hooks/useRenderCount";
import { useWallet } from "@app/hooks/useWallet";
// eslint-disable-next-line no-restricted-imports
import "./Payg.css";
// eslint-disable-next-line no-restricted-imports
import "./PaygFree.css";
// eslint-disable-next-line no-restricted-imports
import UpgradeModal from "./UpgradeModal";
// eslint-disable-next-line no-restricted-imports
import { DocHelp } from "./Payg";
// ─── Shared free-tier snapshot ────────────────────────────
interface FreeSnapshot {
/** One-time free documents used so far (grant remaining). */
billableUsed: number;
/**
* The team's one-time free grant size in documents. Real value from the
* wallet endpoint (pricing_policy.free_tier_units); 500 below is only the
* pre-load placeholder for the first paint.
*/
billableLimit: number;
}
/**
* Read free-tier snapshot from the real {@link useWallet} hook. Falls back to
* a zeroed view if the wallet hasn't loaded yet — this only happens briefly on
* first paint; once the snapshot arrives the component re-renders with real
* numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked
* into the rendered UI and made the page look like nothing was wired up.
*/
function useFreeSnapshot(): FreeSnapshot {
const { wallet } = useWallet();
return useMemo(() => {
if (wallet) {
return {
// Used = grant remaining, derived straight from the one-time grant so
// the free view never depends on the per-state meaning of billableUsed.
billableUsed: Math.max(0, wallet.freeAllowance - wallet.freeRemaining),
// The free view's ceiling IS the one-time grant size.
billableLimit: wallet.freeAllowance,
};
}
return { billableUsed: 0, billableLimit: 500 };
}, [wallet]);
}
type MeterState = "FULL" | "WARNED" | "DEGRADED";
/** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */
function meterState(used: number, limit: number): { state: MeterState; pct: number } {
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
const state: MeterState =
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
return { state, pct };
}
// ─── Editor plan card (always-free tools only) ────────────────────────────
interface EditorPlanCardProps {
/** Role pill text on the right. */
pill: string;
/** LEADER pill colour treatment. */
leader?: boolean;
}
/**
* The top card: the free Editor plan. Manual tools only, no billing window —
* the one-time grant lives in the Processor card below, so there's no period
* to show here.
*/
function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
const { t } = useTranslation();
return (
<div className="payg-planhead paygf-editorcard">
<div className="payg-planhead__top">
<span className="payg-planhead__lbl payg-planhead__lbl--free paygf-editorcard__eyebrow">
<AllInclusiveIcon
className="payg-planhead__lbl-icon"
fontSize="small"
/>
{t("payg.free.editor.eyebrow", "Editor plan · Always free")}
</span>
<span className="payg-role-pill" data-leader={leader ? "true" : "false"}>
{pill}
</span>
</div>
<p className="payg-planhead__title">
{t("payg.free.header.freeTitle", "Unlimited PDF editing")}
</p>
<p className="payg-planhead__body">
{t(
"payg.free.header.freeBody",
"View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.",
)}
</p>
</div>
);
}
// ─── Compact one-time free meter (right column of the Processor card) ──────
function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const stateLabel =
state === "DEGRADED"
? t("payg.free.state.limitReached", "Limit reached")
: state === "WARNED"
? t("payg.free.state.approachingLimit", "Approaching limit")
: t("payg.free.state.plentyLeft", "Plenty left");
return (
<div className="paygf-meter" data-state={state}>
<div className="paygf-meter__top">
<div className="paygf-meter__figure">
<span className="paygf-meter__num">
{snap.billableUsed.toLocaleString()}
</span>
<span className="paygf-meter__cap">
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
limit: snap.billableLimit.toLocaleString(),
})}
</span>
</div>
<span className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<span>
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
</span>
<span className="payg-hero__meta-dot"></span>
<span>{t("payg.free.hero.neverResets", "One-time — never resets")}</span>
</div>
</div>
);
}
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
interface ProcessorCardProps {
snap: FreeSnapshot;
/** Leaders get the live CTA; members get the ask-owner note. */
isLeader: boolean;
/** Opens the upgrade modal — leader only. */
onTurnOn?: () => void;
}
function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
const { t } = useTranslation();
return (
<div className="paygf-cta paygf-proc">
<span className="paygf-proc__eyebrow">
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
{t("payg.free.proc.eyebrow", "Processor plan · metered")}
</span>
<div className="paygf-proc__split">
<div className="paygf-proc__pitch">
<h3 className="paygf-cta__title">
{t("payg.free.cta.title", "Turn on the Processor plan")}
</h3>
<p className="paygf-cta__subtitle">
{t(
"payg.free.cta.subtitle",
"Keep going past your {{limit}} free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control.",
{ limit: snap.billableLimit.toLocaleString() },
)}
</p>
<ul className="paygf-cta__benefits paygf-proc__benefits">
<li>
<CheckIcon className="paygf-cta__check" fontSize="small" />
<span>
<strong>
{t("payg.free.cta.benefit1Title", "Automation pipelines")}
</strong>
{" — "}
{t(
"payg.free.cta.benefit1Body",
"chain tools, schedule runs, batch process",
)}
</span>
</li>
<li>
<CheckIcon className="paygf-cta__check" fontSize="small" />
<span>
<strong>{t("payg.free.cta.benefit2Title", "AI tools")}</strong>
{" — "}
{t(
"payg.free.cta.benefit2Body",
"summarise, classify, redact, AI-OCR",
)}
</span>
</li>
<li>
<CheckIcon className="paygf-cta__check" fontSize="small" />
<span>
<strong>{t("payg.free.cta.benefit3Title", "API access")}</strong>
{" — "}
{t(
"payg.free.cta.benefit3Body",
"call any Stirling endpoint programmatically",
)}
</span>
</li>
</ul>
<DocHelp />
</div>
<div className="paygf-proc__aside">
<FreeMeterPanel snap={snap} />
{isLeader ? (
<>
<button
type="button"
className="paygf-cta__button paygf-proc__cta"
onClick={onTurnOn}
data-testid="turn-on-processor"
>
{t("payg.free.cta.button", "Turn on Processor →")}
</button>
<span className="paygf-cta__reassurance paygf-proc__reassure">
{t(
"payg.free.cta.reassurance",
"No minimum · Set a $0 cap to test · Cancel anytime",
)}
</span>
</>
) : (
<div className="paygf-proc__membernote">
<LockIcon
className="paygf-proc__membernote-icon"
fontSize="small"
/>
<span>
{t(
"payg.free.member.ownerOnly",
"Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like.",
)}
</span>
</div>
)}
</div>
</div>
</div>
);
}
// ─── Free LEADER ──────────────────────────────────────────────────────────
export interface PaygFreeLeaderProps {
/**
* Called when the user finishes the {@link UpgradeModal} checkout flow.
* Plumbed up to {@code PlanSection} so the page can flip to the subscribed
* view immediately. When undefined we fall back to a demo {@code alert} so
* the dev preview route still works in isolation.
*/
onUpgraded?: (result: { capUsd: number | null }) => void;
}
function PaygFreeLeaderInner({ onUpgraded }: PaygFreeLeaderProps = {}) {
useRenderCount("PaygFreeLeader");
const { t } = useTranslation();
const snap = useFreeSnapshot();
const { wallet } = useWallet();
const [upgradeOpen, setUpgradeOpen] = useState(false);
return (
<div className="payg">
<Stack gap="md">
<EditorPlanCard pill={t("payg.role.leader", "Team owner")} leader />
<ProcessorCard
snap={snap}
isLeader
onTurnOn={() => setUpgradeOpen(true)}
/>
</Stack>
{wallet?.teamId != null && (
<UpgradeModal
open={upgradeOpen}
teamId={wallet.teamId}
freeLimit={snap.billableLimit}
pricePerDocMinor={wallet.pricePerDocMinor}
rateCurrency={wallet.currency}
onClose={() => setUpgradeOpen(false)}
onComplete={({ capUsd }) => {
setUpgradeOpen(false);
if (onUpgraded) {
onUpgraded({ capUsd });
} else {
// Standalone fallback (dev preview route renders without a parent
// handler). Real flow always passes onUpgraded via PlanSection.
alert(
`Demo: subscription complete. Cap = ${
capUsd === null ? "no cap" : `$${capUsd}/mo`
}.`,
);
}
}}
/>
)}
</div>
);
}
// ─── Free MEMBER ──────────────────────────────────────────────────────────
function PaygFreeMemberInner() {
useRenderCount("PaygFreeMember");
const { t } = useTranslation();
const snap = useFreeSnapshot();
return (
<div className="payg">
<Stack gap="md">
<EditorPlanCard pill={t("payg.role.member", "Member")} />
<ProcessorCard snap={snap} isLeader={false} />
</Stack>
</div>
);
}
// React.memo so Plan re-rendering on loading/error toggles doesn't cascade
// down to these leaves. Plan passes a stable onUpgraded callback (hoisted in
// Plan.tsx) so the prop identity stays stable across wallet refetches.
export const PaygFreeLeader = React.memo(PaygFreeLeaderInner);
export const PaygFreeMember = React.memo(PaygFreeMemberInner);
@@ -1,245 +1,91 @@
import React, { lazy, Suspense, useState, useCallback, useEffect } from "react";
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
import { usePlans, PlanTier } from "@app/hooks/usePlans";
import type {
PurchaseType,
CreditsPack,
PlanID,
} from "@app/components/shared/StripeCheckoutSaas";
const StripeCheckout = lazy(
() => import("@app/components/shared/StripeCheckoutSaas"),
);
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection";
import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection";
import { useAuth } from "@app/auth/UseSession";
/**
* SaaS "Plan" page — the single entry point for billing, plan state, and
* usage. Branches on the team's wallet state and the viewer's role:
*
* - free + leader → {@link PaygFreeLeader} (upgrade CTA + manual-tools
* framing)
* - free + member → {@link PaygFreeMember} (ask-the-owner note)
* - subscribed + leader → {@link PaygLeader} (full dashboard, editable cap)
* - subscribed + member → {@link PaygMember} (member dashboard)
*
* <p>The hook handles loading + error states locally so the four view
* components stay focused on rendering the data they own. {@code Plan} is
* intentionally tiny (under 60 lines) so future "Plan-level" affordances —
* a top-level error toast, a subscription confirmation card, etc — have
* obvious places to land.
*/
import React, { useCallback } from "react";
import { Alert, Center, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useWallet } from "@app/hooks/useWallet";
import { useRenderCount } from "@app/hooks/useRenderCount";
import {
PaygLeader,
PaygMember,
} from "@app/components/shared/config/configSections/Payg";
import {
PaygFreeLeader,
PaygFreeMember,
} from "@app/components/shared/config/configSections/PaygFree";
const Plan: React.FC = () => {
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
const [purchaseType, setPurchaseType] =
useState<PurchaseType>("subscription");
const [selectedCreditsPack, setSelectedCreditsPack] =
useState<CreditsPack>(null);
const [currency, setCurrency] = useState<string>("gbp");
const { trialStatus } = useAuth();
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
useRenderCount("Plan");
const { t } = useTranslation();
const { wallet, loading, error, markSubscribed, updateCap, openPortal } =
useWallet();
const currencyOptions = [
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
{ value: "usd", label: "US dollar (USD, $)" },
{ value: "inr", label: "Indian rupee (INR, ₹)" },
{ value: "brl", label: "Brazilian real (BRL, R$)" },
{ value: "eur", label: "Euro (EUR, €)" },
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
{ value: "gbp", label: "British pound (GBP, £)" },
];
const handleUpgradeClick = useCallback(
(plan: PlanTier) => {
if (!data) return;
if (plan.isContactOnly) {
// Open contact form or redirect to contact page
window.open(
"mailto:[email protected]?subject=Enterprise Plan Inquiry",
"_blank",
);
return;
}
if (plan.id !== data.currentPlan.id) {
setSelectedPlan(plan);
setPurchaseType("subscription");
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
// Stable callback so PaygFreeLeader's React.memo doesn't see a new prop
// identity on every Plan render (e.g. loading flips false→true→false on
// a refetch). Closing over the stable markSubscribed from useWallet
// means we don't need to add wallet state to deps.
const onUpgraded = useCallback(
({ capUsd }: { capUsd: number | null }) => {
// Bridges the modal's local success → backend mock → refetch loop.
// Real Stripe flow: the customer.subscription.created webhook is
// what flips status; we still call markSubscribed locally so the
// optimistic refetch hits immediately.
void markSubscribed(capUsd);
},
[data],
[markSubscribed],
);
const handleCreditPurchaseClick = useCallback(
(creditsPack: CreditsPack) => {
if (!data) return;
setSelectedCreditsPack(creditsPack);
setPurchaseType("credits");
setSelectedPlan(null);
setCheckoutOpen(true);
},
[data],
);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log("Payment successful, session:", sessionId);
// Update local state immediately - no page reload needed
if (selectedPlan && purchaseType === "subscription") {
updateCurrentPlan(selectedPlan.id);
}
// Close modal after brief delay to show success message
setTimeout(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, 2000);
},
[selectedPlan, purchaseType, updateCurrentPlan],
);
const handlePaymentError = useCallback((error: string) => {
console.error("Payment error:", error);
// Error is already displayed in the StripeCheckout component
}, []);
const handleCheckoutClose = useCallback(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, []);
const handleAddPaymentClick = useCallback(() => {
if (!data) return;
// Find Pro plan from available plans
const proPlan = Array.from(data.plans.values()).find(
(plan) => plan.id === "pro",
);
if (proPlan) {
setSelectedPlan(proPlan);
setPurchaseType("subscription");
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
}, [data]);
// Check URL parameters for action=add-payment
useEffect(() => {
if (!data) return;
const params = new URLSearchParams(window.location.search);
if (params.get("action") === "add-payment") {
handleAddPaymentClick();
// Clean up URL
params.delete("action");
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
}, [data, handleAddPaymentClick]);
// Early returns after all hooks are called
if (loading) {
if (loading && !wallet) {
return (
<div className="flex justify-center items-center py-8">
<Loader size="lg" />
</div>
<Center mih={200}>
<Loader />
</Center>
);
}
if (error) {
if (error || !wallet) {
return (
<Alert color="red" title="Error loading plans">
{error}
<Alert color="red" title={t("payg.error.title", "Couldn't load your plan")}>
{error ??
t(
"payg.error.body",
"We couldn't reach the billing service. Refresh the page to try again.",
)}
</Alert>
);
}
if (!data) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
if (wallet.status === "subscribed") {
return wallet.role === "leader" ? (
<PaygLeader
wallet={wallet}
onSaveCap={updateCap}
onOpenPortal={openPortal}
/>
) : (
<PaygMember wallet={wallet} />
);
}
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } =
data;
const plansArray = Array.from(plans.values());
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Currency Selector */}
<div>
<Group justify="space-between" align="center" mb="md">
<Text size="lg" fw={600}>
Currency
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || "gbp")}
data={currencyOptions}
searchable
clearable={true}
w={300}
/>
</Group>
</div>
<ActivePlanSection
currentPlan={currentPlan}
_activeSince={activeSince}
_nextBillingDate={nextBillingDate}
trialStatus={trialStatus ?? undefined}
onAddPaymentClick={handleAddPaymentClick}
/>
<Divider />
<AvailablePlansSection
plans={plansArray}
currentPlan={currentPlan}
onUpgradeClick={handleUpgradeClick}
/>
<Divider />
<ApiPackagesSection
apiPackages={apiPackages}
selectedCredits={selectedCredits}
onSelectedCreditsChange={setSelectedCredits}
onCreditPurchaseClick={handleCreditPurchaseClick}
/>
{/* Stripe Checkout Modal */}
{checkoutOpen &&
(selectedPlan !== null || selectedCreditsPack !== null) && (
<Suspense fallback={null}>
<StripeCheckout
opened={true}
onClose={handleCheckoutClose}
purchaseType={purchaseType}
planId={
purchaseType === "subscription"
? (selectedPlan?.id as PlanID)
: null
}
creditsPack={
purchaseType === "credits" ? selectedCreditsPack : null
}
planName={
purchaseType === "subscription"
? selectedPlan?.name || ""
: data?.apiPackages.find(
(pkg) => pkg.id === selectedCreditsPack,
)?.name || ""
}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
isTrialConversion={
trialStatus?.isTrialing && purchaseType === "subscription"
}
/>
</Suspense>
)}
</div>
);
// Free tier — only the leader sees the upgrade CTA.
if (wallet.role === "leader") {
return <PaygFreeLeader onUpgraded={onUpgraded} />;
}
return <PaygFreeMember />;
};
export default Plan;
@@ -0,0 +1,159 @@
/* Reusable monthly spend-cap control — preset chips + an inline custom-entry
pill + a no-cap chip, with a live "≈ N PDFs/month" estimate beneath. Shared
by the subscribed plan-page cap editor (Payg.tsx) and the upgrade checkout
flow (UpgradeModal.tsx).
Self-contained tokens so the control looks right whether it's dropped into
the Plan card (light/dark modal content) or the dark upgrade modal. We lean
on the app's semantic theme tokens and override per scheme, mirroring the
Payg.css / UpgradeModal.css treatments the control sits beside. */
.scc {
--scc-accent: #0a8bff;
--scc-accent-text: #0a8bff;
--scc-accent-soft: rgba(10, 139, 255, 0.12);
--scc-accent-border: rgba(10, 139, 255, 0.25);
--scc-chip-bg: var(--bg-muted);
--scc-chip-border: var(--border-default);
/* Consistent vertical rhythm between the control row, the estimate, and any
note — without it the blocks sit flush. */
display: flex;
flex-direction: column;
gap: 14px;
}
[data-mantine-color-scheme="dark"] .scc {
--scc-accent-text: #66b8ff;
--scc-accent-soft: rgba(10, 139, 255, 0.16);
--scc-chip-bg: #272d35;
--scc-chip-border: #3d444e;
}
/* ── Inline row: presets · custom · no-cap · (save) ──────────────────── */
.scc-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* Shared pill shape for presets, the custom-entry pill, and no-cap. Keeping a
single height/radius/border is what makes the custom field read as "one more
button" rather than a form input bolted on. */
.scc-chip {
display: inline-flex;
align-items: center;
height: 34px;
padding: 0 16px;
border-radius: 999px;
border: 1px solid var(--scc-chip-border);
background: var(--scc-chip-bg);
color: var(--text-secondary);
font: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
font-variant-numeric: tabular-nums;
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.scc-chip:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--border-strong);
}
.scc-chip[data-selected="true"] {
background: var(--scc-accent-soft);
border-color: var(--scc-accent);
color: var(--scc-accent-text);
}
.scc-chip:disabled {
opacity: 0.45;
cursor: default;
}
/* Custom-entry pill — Option A: a dashed pill that matches the presets and
reads as "or type your own", filling solid like a selected chip once it
carries a value. */
.scc-custom {
display: inline-flex;
align-items: center;
gap: 1px;
height: 34px;
padding: 0 14px;
border-radius: 999px;
border: 1px dashed var(--scc-chip-border);
background: transparent;
cursor: text;
transition: border-color 0.12s ease, background 0.12s ease;
}
.scc-custom:hover {
border-color: var(--border-strong);
}
.scc-custom[data-active="true"] {
border-style: solid;
border-color: var(--scc-accent);
background: var(--scc-accent-soft);
}
.scc-custom__symbol {
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 600;
}
.scc-custom[data-active="true"] .scc-custom__symbol,
.scc-custom[data-active="true"] .scc-custom__input {
color: var(--scc-accent-text);
}
.scc-custom__input {
width: 70px;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.scc-custom__input::placeholder {
color: var(--text-muted);
font-weight: 600;
}
.scc-custom:disabled,
.scc-custom[data-disabled="true"] {
opacity: 0.45;
cursor: default;
}
.scc-row__spacer {
margin-left: auto;
}
/* ── Live PDF estimate ───────────────────────────────────────────────── */
.scc-estimate {
display: flex;
align-items: center;
gap: 11px;
padding: 12px 14px;
border-radius: 10px;
background: var(--scc-accent-soft);
border: 1px solid var(--scc-accent-border);
}
.scc-estimate__icon {
color: var(--scc-accent-text);
display: flex;
}
.scc-estimate__main {
font-size: 0.875rem;
font-weight: 550;
color: var(--text-primary);
}
.scc-estimate__sub {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
/* Quiet helper line (e.g. "Shown in USD — change later in your currency"). */
.scc-note {
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1.45;
}
@@ -0,0 +1,245 @@
/**
* Reusable monthly spend-cap control.
*
* One inline row — preset chips, a custom-entry pill that matches the presets,
* a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs /
* month" estimate. Extracted from the subscribed plan-page cap editor so the
* exact same control drives the upgrade checkout flow.
*
* <h2>Currency-agnostic by design</h2>
*
* The control never decides a currency. It takes {@code pricePerDocMinor} +
* {@code currency} and renders whatever it's handed: the subscribed plan page
* passes the team's real Stripe-subscription rate/currency; the unsubscribed
* checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
* yet) plus a {@code note} explaining the cap is editable later. When no rate
* is supplied the estimate simply hides.
*
* <h2>Controlled</h2>
*
* Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
* real $0 cap that keeps everything free) + {@code onChange}. The parent owns
* the working value. When {@code onSave} is provided the control renders the
* inline Save button and computes "dirty" against {@code savedCapUsd}.
*/
import React, { useState } from "react";
import { Button } from "@mantine/core";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useTranslation } from "react-i18next";
// eslint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
// Quick amounts offered everywhere — recognition over recall.
export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
export interface SpendCapControlProps {
/** Current cap in major currency units; {@code null} = no cap. Controlled. */
capUsd: number | null;
/** Working-value setter. {@code null} signals no-cap. */
onChange: (capUsd: number | null) => void;
/** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
pricePerDocMinor?: number | null;
/** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
currency?: string | null;
/** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
presets?: readonly number[];
/**
* When provided, the control renders an inline Save button. Receives whole
* major units, or {@code null} for no-cap.
*/
onSave?: (capUsd: number | null) => Promise<void> | void;
/** Label for the Save button. */
saveLabel?: string;
/**
* The persisted value to diff against for the dirty check. Same encoding as
* {@link #capUsd} ({@code null} = persisted no-cap). Only used with
* {@link #onSave}.
*/
savedCapUsd?: number | null;
/** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
note?: React.ReactNode;
}
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
function formatMinor(minor: number, currency: string | null | undefined): string {
const code = (currency ?? "usd").toUpperCase();
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
// Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
// rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
maximumFractionDigits: 3,
}).format(minor / 100);
} catch {
return `${(minor / 100).toFixed(2)} ${code}`;
}
}
/** Currency symbol for compact inline use; falls back to the ISO code. */
function currencySymbol(currency: string | null | undefined): string {
switch ((currency ?? "").toLowerCase()) {
case "usd":
case "":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
default:
return currency!.toUpperCase() + " ";
}
}
const SpendCapControl: React.FC<SpendCapControlProps> = ({
capUsd,
onChange,
pricePerDocMinor,
currency,
presets = DEFAULT_CAP_PRESETS,
onSave,
saveLabel,
savedCapUsd,
note,
}) => {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const sym = currencySymbol(currency);
const isNoCap = capUsd === null;
const presetSelected = capUsd != null && presets.includes(capUsd);
// Custom is "active" when a cap is set that isn't one of the presets — i.e.
// the value came from the custom pill.
const customActive = capUsd != null && !presets.includes(capUsd);
// Local mirror of the custom field's text so partial/empty entry doesn't get
// clobbered by the controlled value. Seeded from a non-preset incoming cap.
const [customText, setCustomText] = useState<string>(
customActive ? String(capUsd) : "",
);
// Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
// one-time free grant is a separate lifetime pool and is NOT added here —
// this is the paid PDFs the monthly cap buys.
const rate =
pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
const previewDocs =
capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
const selectPreset = (preset: number) => {
setCustomText("");
onChange(preset);
};
const selectNoCap = () => {
setCustomText("");
onChange(null);
};
const onCustomInput = (raw: string) => {
// Digits only; an empty field reads as "no custom value yet" → 0 so the
// estimate still renders sensibly without flipping to no-cap.
const cleaned = raw.replace(/[^0-9]/g, "");
setCustomText(cleaned);
const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
onChange(Number.isNaN(v) ? 0 : v);
};
const handleSave = async () => {
if (!onSave) return;
setSaving(true);
try {
await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
} finally {
setSaving(false);
}
};
return (
<div className="scc">
<div className="scc-row">
{presets.map((preset) => (
<button
key={preset}
type="button"
className="scc-chip"
data-selected={presetSelected && capUsd === preset}
onClick={() => selectPreset(preset)}
>
{sym}
{preset.toLocaleString()}
</button>
))}
{/* Custom-entry pill — dashed until it carries a value, then it fills
like a selected chip. */}
<label className="scc-custom" data-active={customActive}>
<span className="scc-custom__symbol">{sym}</span>
<input
className="scc-custom__input"
inputMode="numeric"
value={customActive ? customText : ""}
placeholder={t("payg.cap.custom", "Custom")}
aria-label={t("payg.cap.amount", "Cap amount")}
onChange={(e) => onCustomInput(e.target.value)}
/>
</label>
<button
type="button"
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
data-selected={isNoCap}
onClick={selectNoCap}
>
{t("payg.cap.noCapLabel", "No cap")}
</button>
{onSave && (
<Button
variant="default"
size="xs"
className="scc-row__spacer"
disabled={!dirty || saving}
loading={saving}
leftSection={<LocalIcon icon="check-rounded" />}
onClick={handleSave}
>
{saveLabel ?? t("payg.cap.save", "Update cap")}
</Button>
)}
</div>
{previewDocs != null && (
<div className="scc-estimate">
<DescriptionIcon className="scc-estimate__icon" sx={{ fontSize: 22 }} />
<div>
<div className="scc-estimate__main">
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
docs: previewDocs.toLocaleString(),
})}
</div>
<div className="scc-estimate__sub">
{t("payg.cap.docsRate", "at {{rate}} / PDF", {
rate: formatMinor(pricePerDocMinor ?? 0, currency),
})}
</div>
</div>
</div>
)}
{isNoCap && (
<div className="scc-note">
{t(
"payg.cap.noCapDesc",
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
)}
</div>
)}
{note && <div className="scc-note">{note}</div>}
</div>
);
};
export default SpendCapControl;
@@ -0,0 +1,314 @@
/**
* Stripe Embedded Checkout panel — lives in its own module so it can be
* imported lazily. {@code @stripe/stripe-js} pulls a fairly chunky third-party
* SDK; we don't want it in the main bundle for users who never open the
* UpgradeModal, let alone reach step 2.
*
* <h2>Lazy-load pattern</h2>
*
* <pre>
* // In UpgradeModal.tsx — only when the user advances to step 2:
* const StripeCheckoutPanel = React.lazy(
* () => import("./StripeCheckoutPanel"),
* );
* </pre>
*
* The {@code loadStripe()} call inside this module deferred-imports the SDK
* itself, so the chunk graph is:
*
* <pre>
* StripeCheckoutPanel.chunk.js
* └─ @stripe/react-stripe-js (pulled in by ESM static import here)
* └─ @stripe/stripe-js (pulled in by loadStripe inside fetchStripe)
* </pre>
*
* Both chunks are eligible for Vite tree-shaking + lazy load; nothing in the
* main bundle references either package.
*
* <h2>Architecture</h2>
*
* Stripe-touching code lives in Supabase edge functions, not the Java
* backend. This panel invokes {@code create-checkout-session}
* directly via {@code supabase.functions.invoke()} — same pattern {@link
* usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is
* attached automatically by the Supabase client.
*
* <p>The edge function (SaaS PR #300) is the canonical place Stripe Checkout
* Sessions get created — it uses the Stripe Sync Engine tables, has dedicated
* unit tests, and shares Stripe SDK / secret-key plumbing with the metering +
* webhook edge functions. Routing through Java would have meant a useless
* proxy hop + a second Stripe SDK to maintain.
*
* <h2>Behaviour</h2>
*
* <ol>
* <li>On mount: calls {@code supabase.functions.invoke("create-checkout-session", {team_id,
* currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is
* NOT set here — it's an application-layer setting applied via {@code PATCH /payg/cap} after
* the subscription lands.
* <li>If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge
* function isn't deployed yet (errors out / returns a {@code cs_mock_}
* sentinel), render a clearly-labelled placeholder + "Continue with
* mock" button so the post-completion path stays testable.
* <li>Otherwise render the real {@code <EmbeddedCheckoutProvider>} +
* {@code <EmbeddedCheckout>} iframe.
* </ol>
*
* The parent {@link UpgradeModal} passes {@code onComplete} which fires when
* either the real Stripe checkout emits its complete event OR the user
* presses "Continue with mock" in unconfigured environments.
*/
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { supabase } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
// Eager static imports here are OK because this whole module is itself lazy-
// imported by the modal. They land in the same lazy chunk.
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from "@stripe/react-stripe-js";
import type { Stripe } from "@stripe/stripe-js";
export interface StripeCheckoutPanelProps {
/**
* The caller's team_id — required by the Supabase edge function, which can't
* derive it from the JWT alone (the function runs outside our app's Spring
* Security context and has no access to the {@code team_memberships} table
* other than via this hint).
*/
teamId: number;
/** Currency lower-case 3-letter ISO (e.g. {@code "gbp"}). Selects the Stripe Price. */
currency?: string;
/** Cap in USD; null means no cap. Tracked locally; set on the wallet via PATCH after subscription. */
capUsd: number | null;
/** Called when Stripe (or the mock continue button) signals completion. */
onComplete: () => void;
/** Called when the call to /api/v1/payg/checkout fails. */
onError?: (message: string) => void;
}
/**
* Response shape from the {@code create-checkout-session} Supabase edge
* function. Mirrors what the function returns.
*/
interface CheckoutResponse {
/** Stripe Checkout Session client_secret. */
client_secret: string;
/**
* Optional sentinel: edge functions in non-prod environments may return a
* stubbed secret prefixed {@code cs_mock_} so the FE knows to render the
* placeholder rather than try to mount a real iframe with a bad secret.
*/
mock?: boolean;
}
// Singleton Stripe promise — created on first use and reused for the lifetime
// of the tab. {@code loadStripe} is dynamically imported so the actual SDK
// chunk is only pulled when this code path runs.
let stripePromise: Promise<Stripe | null> | null = null;
function getStripe(publishableKey: string): Promise<Stripe | null> {
if (stripePromise === null) {
stripePromise = import("@stripe/stripe-js").then((mod) =>
mod.loadStripe(publishableKey),
);
}
return stripePromise;
}
const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
teamId,
currency = "gbp",
// capUsd is part of the props contract but intentionally unused here — the cap is set
// application-side via PATCH /payg/cap after the subscription lands, not during checkout.
onComplete,
onError,
}) => {
const { t } = useTranslation();
const { user } = useAuth();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [isMock, setIsMock] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Billing email for the Stripe Checkout Session. Passing customer_email to
// Stripe prefills the email field AND locks it (Stripe's own behaviour for
// sessions created with customer_email or an attached customer) — the user
// can't bill a different address than the account they're signed in with.
const billingEmail = user?.email ?? null;
// Stable ref for the error callback so we don't have to include it in the
// effect deps.
const onErrorRef = useRef<typeof onError>(onError);
onErrorRef.current = onError;
// Stash t() in a ref so the effect can read the current translator without
// forcing t into its deps.
const tRef = useRef(t);
tRef.current = t;
const publishableKey =
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
// Dev preview route has no backend — skip the API call and go straight
// to the mock placeholder so the design + completion path stay testable.
// Both checks required so a production tenant with a /dev/ URL prefix
// can't accidentally trigger the placeholder.
const devPreview =
import.meta.env.DEV &&
typeof window !== "undefined" &&
window.location.pathname.startsWith("/dev/");
useEffect(() => {
if (devPreview) {
setClientSecret("cs_mock_devpreview");
setIsMock(true);
setLoading(false);
return;
}
// React 18 strict-mode dev mounts effects twice. We use `cancelled` to
// discard the first mount's response (its setState calls become no-ops),
// and the second mount's response wins. We deliberately do NOT short-
// circuit the second mount with a ref — earlier versions did that and
// ran into the trap where mount 1 was cancelled but the live mount
// never re-fetched, leaving `loading` stuck at true. Two network calls
// in dev is an acceptable cost; prod has no strict mode → single fetch.
let cancelled = false;
async function createSession() {
try {
// Direct Supabase edge function invocation. We call
// {@code create-checkout-session} (not {@code create-payg-team-subscription} —
// that one creates a subscription directly without going through the
// hosted Stripe Embedded Checkout iframe and doesn't return a
// client_secret). team_id is required because the edge fn runs
// outside our Spring Security context and can't resolve it from the
// JWT alone. The cap is *not* set during checkout — it's an
// application-layer setting, applied via PATCH /payg/cap after the
// subscription lands. window.location.href is the success_url so the
// user comes back to the Plan tab after Stripe finishes the redirect.
const returnUrl = window.location.href;
const { data, error: invokeError } =
await supabase.functions.invoke<CheckoutResponse>(
"create-checkout-session",
{
body: {
team_id: teamId,
currency,
success_url: returnUrl,
cancel_url: returnUrl,
// Maps to Stripe's customer_email when the team has no Stripe
// customer yet — prefills + locks the email field in Checkout.
// Teams with an existing customer get the email locked from the
// customer record instead; this field is ignored for them.
...(billingEmail ? { billing_owner_email: billingEmail } : {}),
},
},
);
if (invokeError) {
throw invokeError;
}
if (!data?.client_secret) {
throw new Error("Edge function returned no client_secret");
}
if (cancelled) return;
setClientSecret(data.client_secret);
setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"));
} catch (e: unknown) {
if (cancelled) return;
const msg =
e instanceof Error
? e.message
: tRef.current(
"payg.checkout.error.startFailed",
"Couldn't start checkout session",
);
setError(msg);
onErrorRef.current?.(msg);
} finally {
if (!cancelled) setLoading(false);
}
}
void createSession();
return () => {
cancelled = true;
};
}, [teamId, currency, devPreview, billingEmail]);
if (loading) {
return (
<div className="upm-stripe-mount" data-state="loading">
<div className="upm-stripe-mount__title">
{t("payg.checkout.connecting", "Connecting to Stripe…")}
</div>
</div>
);
}
if (error) {
return (
<div className="upm-stripe-mount" data-state="error">
<div className="upm-stripe-mount__title">
{t("payg.checkout.errorTitle", "Stripe error")}
</div>
<div>{error}</div>
</div>
);
}
// Mock mode OR no publishable key configured → friendly placeholder.
const showMockPlaceholder = isMock || publishableKey.length === 0;
if (showMockPlaceholder) {
return (
<div className="upm-stripe-mount" data-state="mock">
<div className="upm-stripe-mount__title">
{t(
"payg.checkout.mock.title",
"Stripe Embedded Checkout (mock mode)",
)}
</div>
<div>
{publishableKey.length === 0
? t(
"payg.checkout.mock.noKey",
"VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured.",
)
: t(
"payg.checkout.mock.backend",
"Backend is in mock mode — no real Stripe session was created.",
)}
</div>
<div style={{ marginTop: 12 }}>
<button
type="button"
className="upm-btn"
data-variant="primary"
onClick={onComplete}
>
{t(
"payg.checkout.mock.continue",
"Continue with mock subscription",
)}
</button>
</div>
</div>
);
}
if (!clientSecret) return null;
return (
<div className="upm-stripe-mount" data-state="live">
<EmbeddedCheckoutProvider
stripe={getStripe(publishableKey)}
options={{ clientSecret, onComplete }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
};
export default StripeCheckoutPanel;
@@ -0,0 +1,440 @@
/* Upgrade-to-Processor modal: two steps inside one frame.
Step 1: pick a monthly cap.
Step 2: Stripe Embedded Checkout (or placeholder when no test key set).
The frame doesn't change between steps — only the panel slides — so the
user feels like they're filling out one form, not navigating pages. */
.upm {
--upm-accent: #0a8bff;
--upm-accent-2: #6c5ce7;
--upm-accent-soft: rgba(10, 139, 255, 0.08);
--upm-success: #10b981;
--upm-radius: 18px;
--upm-card-bg: var(--bg-surface);
--upm-border: var(--border-default);
--upm-divider: var(--border-subtle);
--upm-text: var(--text-primary);
--upm-muted: var(--text-muted);
}
[data-mantine-color-scheme="dark"] .upm {
--upm-card-bg: #313842;
--upm-border: #3d444e;
--upm-divider: #3d444e;
--upm-accent-soft: rgba(10, 139, 255, 0.14);
}
/* Backdrop locks the page and centres the modal. z-index matches
Z_INDEX_OVER_SETTINGS_MODAL (styles/zIndex.ts) — must sit above the
AppConfigModal's 1300 (Z_INDEX_OVER_FULLSCREEN_SURFACE). The config modal
also hides itself while we're open (appConfig:overlay event), so this is
belt-and-braces for the brief overlap during open/close transitions. */
.upm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
z-index: 1400;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
animation: upm-fade-in 180ms ease;
}
@keyframes upm-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.upm-frame {
background: var(--upm-card-bg);
border: 1px solid var(--upm-border);
border-radius: var(--upm-radius);
width: 100%;
/* Stripe Embedded Checkout flips from its single-column layout to the two-column
"horizontal" one (order summary beside the payment form) once the iframe is
~1000px wide — measured empirically against a live test session; Stripe doesn't
document the breakpoint. The live iframe gets frame_width 44px (the 22px body
padding each side; the live mount's own padding is removed — see
.upm-stripe-mount[data-state="live"]). So 1100px → ~1056px iframe, comfortably
past the ~1000px threshold. Because the frame stays width:100% under this cap,
narrow windows shrink it and Stripe falls back to single column — horizontal
when it fits, vertical when it doesn't. Cap + confirm steps read fine at this
width (their internal text is already capped — see upm-confirm__body,
upm-section-help). */
max-width: 1100px;
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.4);
animation: upm-pop-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes upm-pop-in {
from {
opacity: 0;
transform: translateY(12px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ── Header: title + close + step dots ────────────────────────────────── */
.upm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--upm-divider);
}
.upm-header__title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--upm-text);
margin: 0;
}
.upm-header__close {
background: transparent;
border: none;
color: var(--upm-muted);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 120ms ease, color 120ms ease;
}
.upm-header__close:hover {
background: var(--upm-divider);
color: var(--upm-text);
}
/* Left cluster: optional back arrow + title. The back arrow only renders on the
checkout step (cap/confirm have no parent step to return to) — it replaces the
old footer "← Back" button. */
.upm-header__left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.upm-header__back {
background: transparent;
border: none;
color: var(--upm-muted);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: -6px;
transition: background 120ms ease, color 120ms ease;
}
.upm-header__back:hover {
background: var(--upm-divider);
color: var(--upm-text);
}
.upm-steps {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 22px 14px;
background: var(--upm-accent-soft);
border-bottom: 1px solid var(--upm-divider);
}
.upm-step {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
font-size: 0.78rem;
font-weight: 600;
color: var(--upm-muted);
transition: color 200ms ease;
}
.upm-step__dot {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--upm-card-bg);
border: 1.5px solid var(--upm-divider);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
}
.upm-step[data-state="active"] {
color: var(--upm-text);
}
.upm-step[data-state="active"] .upm-step__dot {
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
border-color: transparent;
color: white;
}
.upm-step[data-state="done"] {
color: var(--upm-success);
}
.upm-step[data-state="done"] .upm-step__dot {
background: var(--upm-success);
border-color: transparent;
color: white;
}
.upm-step__connector {
flex: 1 1 auto;
height: 1.5px;
background: var(--upm-divider);
margin: 0 4px;
}
/* ── Body: slides between steps without remounting ───────────────────── */
.upm-body {
padding: 22px;
overflow-y: auto;
flex: 1 1 auto;
}
/* Hero promise reinforcement — only shown on step 1 */
.upm-promise {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: var(--upm-accent-soft);
border-radius: 12px;
margin-bottom: 18px;
font-size: 0.85rem;
line-height: 1.45;
color: var(--upm-text);
}
.upm-promise__icon {
flex-shrink: 0;
color: var(--upm-accent);
margin-top: 1px;
}
.upm-promise__highlight {
font-weight: 700;
}
.upm-section-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--upm-text);
margin: 0 0 4px;
letter-spacing: -0.005em;
}
.upm-section-help {
font-size: 0.825rem;
color: var(--upm-muted);
margin: 0 0 14px;
line-height: 1.45;
}
/* Cap selection (presets + custom + no-cap + estimate) now lives in the shared
SpendCapControl (SpendCapControl.css). The old upm-cap-* / upm-no-cap-toggle
rules were removed with it. */
/* What counts as "processed" — quiet helper text */
.upm-help-card {
background: var(--upm-divider);
border-radius: 10px;
padding: 12px 14px;
margin-top: 14px;
font-size: 0.78rem;
line-height: 1.5;
color: var(--upm-muted);
}
.upm-help-card__title {
display: block;
font-weight: 700;
color: var(--upm-text);
margin-bottom: 4px;
font-size: 0.8rem;
}
/* On the checkout step, step 1's "Set monthly ceiling" label is replaced (inside
the step bar) by the chosen ceiling + an inline Edit affordance — see
UpgradeModal.tsx. Kept here next to the step-bar markup it styles. */
.upm-step__chosen {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--upm-text);
font-weight: 700;
}
.upm-step__edit {
background: transparent;
border: none;
color: var(--upm-accent);
font-weight: 600;
cursor: pointer;
font-family: inherit;
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 6px;
}
.upm-step__edit:hover {
background: rgba(10, 139, 255, 0.12);
}
/* Placeholder card for the loading / error / mock states — a dashed, centered
box with helper text. The live Stripe iframe is NOT meant to wear this chrome;
it gets a full-width reset below (data-state="live"). */
.upm-stripe-mount {
min-height: 220px;
border: 1.5px dashed var(--upm-divider);
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
color: var(--upm-muted);
font-size: 0.875rem;
}
/* Live Stripe Embedded Checkout — strip the placeholder card chrome so the
iframe spans the full body width and Stripe renders its two-column
"horizontal" layout. Without this the dashed border + 24px padding + centering
above box the iframe ~48px narrower, dropping it back to single column. */
.upm-stripe-mount[data-state="live"] {
display: block;
border: none;
padding: 0;
min-height: 0;
text-align: left;
}
.upm-stripe-mount__title {
font-weight: 700;
color: var(--upm-text);
font-size: 0.95rem;
}
.upm-stripe-mount__code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
padding: 2px 6px;
background: var(--upm-card-bg);
border-radius: 4px;
border: 1px solid var(--upm-divider);
color: var(--upm-text);
}
/* ── Footer / nav actions ─────────────────────────────────────────────── */
.upm-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 22px;
border-top: 1px solid var(--upm-divider);
background: var(--upm-card-bg);
}
.upm-footer__actions {
display: flex;
gap: 8px;
}
.upm-btn {
padding: 10px 18px;
border-radius: 10px;
border: 1.5px solid transparent;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 120ms ease;
font-family: inherit;
white-space: nowrap;
}
.upm-btn[data-variant="ghost"] {
background: transparent;
border-color: var(--upm-divider);
color: var(--upm-text);
}
.upm-btn[data-variant="ghost"]:hover {
border-color: var(--upm-accent);
color: var(--upm-accent);
}
.upm-btn[data-variant="primary"] {
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
color: white;
}
.upm-btn[data-variant="primary"]:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px -6px rgba(10, 139, 255, 0.5);
}
.upm-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* ── Step 3: confirmation ─────────────────────────────────────────────── */
.upm-confirm {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 12px 8px 4px;
gap: 12px;
}
.upm-confirm__icon {
font-size: 64px !important;
color: #10b981;
filter: drop-shadow(0 6px 18px rgba(16, 185, 129, 0.35));
}
.upm-confirm__title {
margin: 6px 0 0;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.015em;
color: var(--upm-text);
}
.upm-confirm__body {
margin: 0;
font-size: 0.95rem;
color: var(--upm-text-muted);
max-width: 380px;
line-height: 1.55;
}
.upm-confirm__summary {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 360px;
padding: 14px 18px;
background: var(--upm-inset-bg, rgba(0, 0, 0, 0.03));
border: 1px solid var(--upm-divider);
border-radius: 12px;
margin-top: 6px;
font-size: 0.92rem;
}
.upm-confirm__summary > strong {
color: var(--upm-text);
font-size: 1.05rem;
}
.upm-confirm__note {
margin: 4px 0 0;
font-size: 0.78rem;
color: var(--upm-text-muted);
max-width: 380px;
line-height: 1.5;
}
@@ -0,0 +1,532 @@
/**
* Upgrade-to-Processor modal. Three sequential panels inside one frame:
*
* Step 1: Cap selection — local state only, no side effects
* Step 2: Stripe Checkout — POSTs to /api/v1/payg/checkout, mounts the
* Stripe Embedded Checkout iframe (lazy-loaded)
* Step 3: Confirmation — brief "Welcome to Processor" beat before
* the modal closes and the parent's
* {@code onComplete} triggers a wallet refetch
*
* <p>The Stripe SDK ({@code @stripe/stripe-js} + {@code @stripe/react-stripe-js})
* is loaded via {@code React.lazy} on a dedicated module so the chunk only
* downloads when the user actually advances to step 2. The main bundle pays
* nothing for users who never open the modal.
*
* <p>Cap state is held locally — nothing reaches the backend until the user
* commits in step 2. A user who cancels mid-modal leaves no side effects.
*/
import React, { Suspense, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import CloseIcon from "@mui/icons-material/CloseRounded";
import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
import CheckCircleIcon from "@mui/icons-material/CheckCircleRounded";
import { useTranslation } from "react-i18next";
// eslint-disable-next-line no-restricted-imports
import "./UpgradeModal.css";
// eslint-disable-next-line no-restricted-imports
import SpendCapControl from "./SpendCapControl";
/**
* Tell the AppConfigModal (or any other full-screen surface listening) that an
* upgrade overlay is opening/closing so it can hide itself rather than stack
* under us. Same window-event pattern the config modal already uses for
* appConfig:navigate / appConfig:notice.
*/
function dispatchOverlay(open: boolean) {
window.dispatchEvent(
new CustomEvent("appConfig:overlay", { detail: { open } }),
);
}
// Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user
// reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the
// chunk-graph reasoning.
const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel"));
interface UpgradeModalProps {
open: boolean;
/**
* The caller's team_id. Threaded through to {@link StripeCheckoutPanel} so the
* {@code create-checkout-session} edge function can scope the subscription to
* the right team.
*/
teamId: number;
/** Called when the user closes the modal without completing checkout. */
onClose: () => void;
/**
* Called after the user confirms cap + completes Stripe checkout. The parent
* is expected to refresh the wallet snapshot which will then show the
* subscribed state.
*/
onComplete: (result: { capUsd: number | null }) => void;
/** ISO 4217 currency code for the cap input. Default USD. */
currency?: "USD" | "EUR" | "GBP";
/**
* The team's one-time free grant in documents — the real {@code
* wallet.freeAllowance}, threaded from the free-leader view so the step copy
* quotes the backend's number instead of a hardcoded one. A lifetime grant,
* not a monthly one.
*/
freeLimit: number;
/**
* Per-document rate in minor units for the live "≈ N paid PDFs/month"
* estimate, threaded from {@code wallet.pricePerDocMinor}. For unsubscribed
* teams the backend resolves this from the default pricing policy's USD
* Price (Stripe hasn't assigned the team a currency yet). Null hides the
* estimate.
*/
pricePerDocMinor?: number | null;
/** Lower-case ISO currency of {@link #pricePerDocMinor} (e.g. {@code "usd"}). */
rateCurrency?: string | null;
}
type Step = "cap" | "checkout" | "confirm";
function currencySymbol(c: UpgradeModalProps["currency"]): string {
switch (c) {
case "EUR":
return "€";
case "GBP":
return "£";
default:
return "$";
}
}
export default function UpgradeModal({
open,
teamId,
onClose,
onComplete,
currency = "USD",
freeLimit,
pricePerDocMinor,
rateCurrency,
}: UpgradeModalProps) {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("cap");
const [capUsd, setCapUsd] = useState<number>(500);
const [noCap, setNoCap] = useState<boolean>(false);
// The config modal hides itself while we're open (it listens for this event)
// so the upgrade flow visually REPLACES it instead of stacking inside it.
// Cleanup fires open=false on unmount too, so the config modal can't get
// stuck hidden if we unmount without a clean close.
useEffect(() => {
dispatchOverlay(open);
return () => dispatchOverlay(false);
}, [open]);
if (!open) {
return null;
}
const effectiveCap = noCap ? null : capUsd;
const sym = currencySymbol(currency);
const goToCheckout = () => setStep("checkout");
const goBackToCap = () => setStep("cap");
const goToConfirm = () => setStep("confirm");
// Modal close → reset internal step so reopening starts at step 1.
const closeAndReset = () => {
setStep("cap");
onClose();
};
// Portal to document.body so the overlay escapes the config modal's portal /
// stacking context. Without this the fixed-position backdrop layers inside
// the Mantine modal (z-index 1300) instead of over the whole page, producing
// the modal-in-modal look.
return createPortal(
<div className="upm" role="dialog" aria-modal="true">
<div className="upm-backdrop" onClick={closeAndReset}>
<div className="upm-frame" onClick={(e) => e.stopPropagation()}>
{/* Header — title + close. Title stays constant; the step indicator
below tells the user where they are. */}
<header className="upm-header">
<div className="upm-header__left">
{step === "checkout" && (
<button
type="button"
className="upm-header__back"
aria-label={t("payg.upgrade.backAria", "Back")}
onClick={goBackToCap}
>
<ArrowBackIcon fontSize="small" />
</button>
)}
<h2 className="upm-header__title">
{step === "confirm"
? t("payg.upgrade.title.confirm", "You're subscribed")
: t("payg.upgrade.title.default", "Upgrade to Processor plan")}
</h2>
</div>
<button
type="button"
className="upm-header__close"
aria-label={t("payg.upgrade.closeAria", "Close")}
onClick={closeAndReset}
>
<CloseIcon fontSize="small" />
</button>
</header>
{/* Step indicator. Hidden on the confirmation panel since the
modal is winding down at that point. */}
{step !== "confirm" && (
<div className="upm-steps">
<div
className="upm-step"
data-state={step === "cap" ? "active" : "done"}
>
<span className="upm-step__dot">1</span>
{step === "checkout" ? (
<span className="upm-step__chosen">
{effectiveCap === null
? t("payg.upgrade.checkout.noCap", "No cap")
: t(
"payg.upgrade.checkout.capValue",
"{{symbol}}{{amount}} / month",
{ symbol: sym, amount: effectiveCap },
)}
<button
type="button"
className="upm-step__edit"
onClick={goBackToCap}
>
{t("payg.upgrade.checkout.edit", "Edit")}
</button>
</span>
) : (
<span>
{t("payg.upgrade.steps.cap", "Set monthly ceiling")}
</span>
)}
</div>
<div className="upm-step__connector" />
<div
className="upm-step"
data-state={step === "checkout" ? "active" : "idle"}
>
<span className="upm-step__dot">2</span>
<span>
{t(
"payg.upgrade.steps.payment",
"Add payment method",
)}
</span>
</div>
</div>
)}
<div className="upm-body">
{step === "cap" && (
<CapStep
capUsd={capUsd}
setCapUsd={setCapUsd}
noCap={noCap}
setNoCap={setNoCap}
pricePerDocMinor={pricePerDocMinor}
rateCurrency={rateCurrency}
/>
)}
{step === "checkout" && (
// Keyed on the cap value so editing the cap → returning to
// step 2 unmounts + remounts the panel, triggering a fresh
// POST /checkout for the new cap. Without the key, the
// StripeCheckoutPanel's fetchedRef short-circuits and the
// session keeps the stale cap.
<CheckoutStep
key={`co:${effectiveCap ?? "nocap"}`}
teamId={teamId}
effectiveCap={effectiveCap}
currency={currency}
onComplete={goToConfirm}
/>
)}
{step === "confirm" && (
<ConfirmationStep
effectiveCap={effectiveCap}
currency={currency}
freeLimit={freeLimit}
/>
)}
</div>
{step !== "checkout" && (
<footer className="upm-footer">
<div className="upm-footer__actions">
{step === "cap" && (
<>
<button
type="button"
className="upm-btn"
data-variant="ghost"
onClick={closeAndReset}
>
{t("payg.upgrade.button.cancel", "Cancel")}
</button>
<button
type="button"
className="upm-btn"
data-variant="primary"
onClick={goToCheckout}
>
{t("payg.upgrade.button.continue", "Continue →")}
</button>
</>
)}
{step === "confirm" && (
<button
type="button"
className="upm-btn"
data-variant="primary"
onClick={() => {
setStep("cap");
onComplete({ capUsd: effectiveCap });
}}
>
{t("payg.upgrade.button.finish", "Finish")}
</button>
)}
</div>
</footer>
)}
</div>
</div>
</div>,
document.body,
);
}
// ─── Step 1: cap selection ──────────────────────────────────────────────
interface CapStepProps {
capUsd: number;
setCapUsd: (v: number) => void;
noCap: boolean;
setNoCap: (v: boolean) => void;
pricePerDocMinor?: number | null;
rateCurrency?: string | null;
}
function CapStep({
capUsd,
setCapUsd,
noCap,
setNoCap,
pricePerDocMinor,
rateCurrency,
}: CapStepProps) {
const { t } = useTranslation();
return (
<>
<div className="upm-promise">
<ShieldIcon className="upm-promise__icon" fontSize="small" />
<div>
<span className="upm-promise__highlight">
{t(
"payg.upgrade.promise.highlight",
"Manual tools stay free, always.",
)}
</span>{" "}
{t(
"payg.upgrade.promise.body",
"You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge.",
)}
</div>
</div>
<h3 className="upm-section-title">
{t(
"payg.upgrade.cap.title",
"Set your monthly spend ceiling",
)}
</h3>
<p className="upm-section-help">
{t(
"payg.upgrade.cap.help",
"We'll never charge above this. Set $0 if you want to keep everything free while testing.",
)}
</p>
{/* Same control the subscribed plan page renders. null = no cap; the
shared control owns the presets, the inline custom-entry pill, the
no-cap chip, and the live processed-PDF estimate. */}
<SpendCapControl
capUsd={noCap ? null : capUsd}
onChange={(v) => {
if (v === null) {
setNoCap(true);
} else {
setNoCap(false);
setCapUsd(v);
}
}}
pricePerDocMinor={pricePerDocMinor}
currency={rateCurrency}
note={t(
"payg.upgrade.cap.usdNote",
"Estimated in USD. You can adjust your cap any time after subscribing — in your own currency.",
)}
/>
<div className="upm-help-card" style={{ marginTop: 16 }}>
<span className="upm-help-card__title">
{t(
"payg.upgrade.help.title",
"What we count toward billing",
)}
</span>
<ul style={{ margin: "4px 0 0", paddingLeft: 18, lineHeight: 1.55 }}>
<li>
<strong>
{t(
"payg.upgrade.help.automationTitle",
"Automation pipelines",
)}
</strong>
{" — "}
{t(
"payg.upgrade.help.automationBody",
"chained tools or scheduled runs that don't need clicks",
)}
</li>
<li>
<strong>
{t("payg.upgrade.help.aiTitle", "AI tools")}
</strong>
{" — "}
{t(
"payg.upgrade.help.aiBody",
"summarise, classify, redact, AI-OCR",
)}
</li>
<li>
<strong>
{t("payg.upgrade.help.apiTitle", "API calls")}
</strong>
{" — "}
{t(
"payg.upgrade.help.apiBody",
"programmatic access to any Stirling endpoint",
)}
</li>
</ul>
<div style={{ marginTop: 8, fontStyle: "italic" }}>
{t(
"payg.upgrade.help.footnote",
"Manual tools — viewing, editing, merging, splitting, signing, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click.",
)}
</div>
</div>
</>
);
}
// ─── Step 2: Stripe Embedded Checkout (lazy-loaded) ────────────────────
interface CheckoutStepProps {
teamId: number;
effectiveCap: number | null;
currency: UpgradeModalProps["currency"];
onComplete: () => void;
}
function CheckoutStep({
teamId,
effectiveCap,
currency,
onComplete,
}: CheckoutStepProps) {
const { t } = useTranslation();
return (
<>
<h3 className="upm-section-title">
{t(
"payg.upgrade.checkout.title",
"Add your payment method",
)}
</h3>
<p className="upm-section-help">
{t(
"payg.upgrade.checkout.help",
"Stripe handles your card details. Stirling never sees them.",
)}
</p>
<Suspense
fallback={
<div className="upm-stripe-mount" data-state="loading">
<div className="upm-stripe-mount__title">
{t("payg.upgrade.checkout.loading", "Loading checkout…")}
</div>
</div>
}
>
<StripeCheckoutPanel
teamId={teamId}
currency={currency?.toLowerCase() ?? "gbp"}
capUsd={effectiveCap}
onComplete={onComplete}
/>
</Suspense>
</>
);
}
// ─── Step 3: confirmation ──────────────────────────────────────────────
interface ConfirmationStepProps {
effectiveCap: number | null;
currency: UpgradeModalProps["currency"];
freeLimit: number;
}
function ConfirmationStep({
effectiveCap,
currency,
freeLimit,
}: ConfirmationStepProps) {
const { t } = useTranslation();
const sym = currencySymbol(currency);
return (
<div className="upm-confirm">
<CheckCircleIcon className="upm-confirm__icon" />
<h3 className="upm-confirm__title">
{t(
"payg.confirm.title",
"Welcome to the Processor plan",
)}
</h3>
<p className="upm-confirm__body">
{t(
"payg.confirm.body",
"Your team can now process documents with automation, AI, and the API beyond your {{limit}} free PDFs.",
{ limit: freeLimit.toLocaleString() },
)}
</p>
<div className="upm-confirm__summary">
<span>{t("payg.confirm.summaryLabel", "Monthly ceiling")}</span>
<strong>
{effectiveCap === null
? t("payg.confirm.noCap", "No cap")
: t("payg.confirm.capValue", "{{symbol}}{{amount}} / month", {
symbol: sym,
amount: effectiveCap,
})}
</strong>
</div>
<p className="upm-confirm__note">
{t(
"payg.confirm.note",
"You can change your cap, cancel, or open the Stripe customer portal any time from this page.",
)}
</p>
</div>
);
}
@@ -1,79 +1,32 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { ApiCredits } from "@app/types/credits";
import { isUserAnonymous } from "@app/auth/supabase";
function coerceNumber(value: unknown, fallback = 0): number {
const n = typeof value === "string" ? Number(value) : (value as number);
return Number.isFinite(n) ? n : fallback;
}
function normalizeCredits(raw: Record<string, unknown>): ApiCredits {
// Accept a variety of possible backend keys to be resilient
return {
weeklyCreditsRemaining: coerceNumber(
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left,
),
weeklyCreditsAllocated: coerceNumber(
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total,
),
boughtCreditsRemaining: coerceNumber(
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left,
),
totalBoughtCredits: coerceNumber(
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total,
),
totalAvailableCredits: coerceNumber(
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total,
),
weeklyResetDate: String(
raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? "",
),
lastApiUsage: String(
raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? "",
),
};
}
export function useCredits() {
const { session, loading, user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const [data, setData] = useState<ApiCredits | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
// Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only
// hasAttempted toggles, so the rest have no setters.
const [data] = useState<ApiCredits | null>(null);
const [isLoading] = useState<boolean>(false);
const [error] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
// Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the
// wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The
// hook surface is preserved for the ApiKeys page consumer (it destructures `data`,
// `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null —
// the consumer's usage widget will render its "no data" state.
const fetchCredits = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const res =
await apiClient.get<Record<string, unknown>>("/api/v1/credits");
const normalized = normalizeCredits(res.data);
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
const isEmpty =
!normalized.weeklyCreditsAllocated &&
!normalized.weeklyCreditsRemaining &&
!normalized.totalBoughtCredits &&
!normalized.boughtCreditsRemaining &&
!normalized.totalAvailableCredits &&
!normalized.weeklyResetDate &&
!normalized.lastApiUsage;
setData(isEmpty ? null : normalized);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setIsLoading(false);
setHasAttempted(true);
}
setHasAttempted(true);
}, []);
useEffect(() => {
if (!loading && session && !hasAttempted && !isAnonymous) {
fetchCredits();
setHasAttempted(true);
}
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
}, [loading, session, hasAttempted, isAnonymous]);
return {
data,
@@ -1,143 +0,0 @@
import React from "react";
import { Card, Text, Group, Flex, Alert, Button, Badge } from "@mantine/core";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import CreditCardIcon from "@mui/icons-material/CreditCard";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
import { ManageBillingButton } from "@app/components/shared/ManageBillingButton";
interface TrialStatus {
isTrialing: boolean;
trialEnd: string;
daysRemaining: number;
hasPaymentMethod: boolean;
hasScheduledSub: boolean;
}
interface ActivePlanSectionProps {
currentPlan: PlanTier;
_activeSince?: string;
_nextBillingDate?: string;
trialStatus?: TrialStatus;
onAddPaymentClick?: () => void;
}
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
currentPlan,
_activeSince,
_nextBillingDate,
trialStatus,
onAddPaymentClick,
}) => {
const { t } = useTranslation();
return (
<div>
<Flex justify="space-between" align="center">
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.activePlan.title", "Active Plan")}
</h3>
<ManageBillingButton
returnUrl={`${window.location.origin}/account`}
trialStatus={trialStatus}
/>
</Flex>
<p
style={{
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t("plan.activePlan.subtitle", "Your current subscription details")}
</p>
{/* Trial Status Alert */}
{trialStatus?.isTrialing && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t("plan.trial.title", "Free Trial Active")}
>
<Text size="sm">
{t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", {
days: trialStatus.daysRemaining,
})}
</Text>
<Text size="xs" c="dimmed">
{t("plan.trial.endDate", "Expires: {{date}}", {
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
})}
</Text>
{trialStatus.hasScheduledSub ? (
<Text size="xs" c="green" fw={500} mt="sm">
{" "}
{t(
"plan.trial.subscriptionScheduled",
"Subscription scheduled - starts {{date}}",
{
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
},
)}
</Text>
) : (
onAddPaymentClick && (
<Button
size="xs"
variant="light"
mt="sm"
onClick={onAddPaymentClick}
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
>
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
</Button>
)
)}
</Alert>
)}
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<div>
<Group gap="xs">
<Text size="lg" fw={600}>
{currentPlan.name}
</Text>
{trialStatus?.isTrialing && (
<Badge color="blue" variant="light">
{t("plan.trial.badge", "Trial")}
</Badge>
)}
</Group>
{/* {activeSince && (
<Text size="sm" c="dimmed">
{t('plan.activeSince', 'Active since {{date}}', { date: activeSince })}
</Text>
)} */}
</div>
<div className="text-right">
<Text size="xl" fw={700}>
{currentPlan.currency}
{currentPlan.price}/month
</Text>
{/* {nextBillingDate && (
<Text size="sm" c="dimmed">
{t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })}
</Text>
)} */}
</div>
</Group>
</Card>
</div>
);
};
export default ActivePlanSection;
@@ -1,126 +0,0 @@
import React from "react";
import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
interface ApiPackage {
id: string;
name: string;
credits: number;
price: number;
currency: string;
description: string;
}
interface ApiPackagesSectionProps {
apiPackages: ApiPackage[];
selectedCredits: number;
onSelectedCreditsChange: (value: number) => void;
onCreditPurchaseClick: (creditsPack: CreditsPack) => void;
}
const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
apiPackages,
selectedCredits,
onSelectedCreditsChange,
onCreditPurchaseClick,
}) => {
const { t } = useTranslation();
return (
<div>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.apiPackages.title", "API Credit Packages")}
</h3>
<p
style={{
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t(
"plan.apiPackages.subtitle",
"Purchase API credits for your applications",
)}
</p>
<Card padding="xl" radius="md" className="mb-4">
<Stack gap="xl">
{/* Credits Selection */}
<div>
<Text size="lg" fw={600} mb="md">
{t("plan.selectCredits", "Select Credit Amount")}
</Text>
<div className="px-4">
<Slider
value={selectedCredits}
onChange={onSelectedCreditsChange}
onChangeEnd={(value) =>
onSelectedCreditsChange(Math.round(value))
}
min={0}
max={3}
step={0.01}
marks={[
{ value: 0, label: "100" },
{ value: 1, label: "500" },
{ value: 2, label: "1K" },
{ value: 3, label: "5K" },
]}
size="lg"
className="mb-6"
label={null}
/>
</div>
</div>
{/* Selected Package Display */}
<Flex gap={"xl"} justify="space-between" align="center">
<div>
<Text size="xl" fw={700}>
{apiPackages[
Math.round(selectedCredits)
].credits.toLocaleString()}{" "}
Credits
</Text>
<Text size="sm" c="dimmed">
{apiPackages[Math.round(selectedCredits)].description}
</Text>
</div>
<div className="">
<Text size="xl" fw={700}>
{apiPackages[Math.round(selectedCredits)].currency}
{apiPackages[Math.round(selectedCredits)].price}
</Text>
<Text size="sm" c="dimmed">
{t("plan.totalCost", "Total Cost")}
</Text>
</div>
<Button
size="lg"
onClick={() =>
onCreditPurchaseClick(
apiPackages[Math.round(selectedCredits)].id as CreditsPack,
)
}
>
{t("plan.purchase", "Purchase")}
</Button>
</Flex>
</Stack>
</Card>
</div>
);
};
export default ApiPackagesSection;
@@ -1,148 +0,0 @@
import React, { useState } from "react";
import { Button, Card, Badge, Text, Collapse } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlan?: PlanTier;
currentLicenseInfo?: unknown;
onUpgradeClick: (plan: PlanTier) => void;
onManageClick?: (plan: PlanTier) => void;
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: Array<{ value: string; label: string }>;
loginEnabled?: boolean;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentPlan,
onUpgradeClick,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
const isUserProOrAbove =
currentPlan?.id === "pro" || currentPlan?.id === "enterprise";
return (
<div>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
style={{
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t(
"plan.availablePlans.subtitle",
"Choose the plan that fits your needs",
)}
</p>
<div
className="flex h-[20rem] mb-4 "
style={{ gap: "1rem", overflowX: "auto" }}
>
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentPlan?.id}
isUserProOrAbove={isUserProOrAbove}
onUpgradeClick={onUpgradeClick}
/>
))}
</div>
<div className="text-center">
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
<Collapse in={showComparison}>
<Card padding="lg" radius="md" withBorder className="mt-4">
<Text size="lg" fw={600} mb="md">
{t("plan.featureComparison", "Feature Comparison")}
</Text>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2">
{t("plan.feature.title", "Feature")}
</th>
{plans.map((plan) => (
<th
key={plan.id}
className="text-center p-2 min-w-24 relative"
>
{plan.name}
{plan.popular && (
<Badge
color="blue"
variant="filled"
style={{
position: "absolute",
top: "0rem",
right: "-2rem",
fontSize: "0.5rem",
fontWeight: "500",
height: "1rem",
padding: "0 0.1rem",
}}
>
{t("plan.popular", "Popular")}
</Badge>
)}
</th>
))}
</tr>
</thead>
<tbody>
{plans[0].features.map((_, featureIndex) => (
<tr key={featureIndex} className="border-b">
<td className="p-2">
{plans[0].features[featureIndex].name}
</td>
{plans.map((plan) => (
<td key={plan.id} className="text-center p-2">
{plan.features[featureIndex].included ? (
<Text c="green" fw={600}>
</Text>
) : (
<Text c="gray">-</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</Collapse>
</div>
);
};
export default AvailablePlansSection;
@@ -1,113 +0,0 @@
import React from "react";
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
interface PlanCardProps {
plan?: PlanTier;
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
isCurrentPlan?: boolean;
isCurrentTier?: boolean;
isDowngrade?: boolean;
isUserProOrAbove?: boolean;
currentLicenseInfo?: unknown;
currentTier?: string | null; // Accept null for proprietary compatibility
onUpgradeClick?: (plan: PlanTier) => void;
onManageClick?: (plan: PlanTier) => void;
loginEnabled?: boolean;
}
const PlanCard: React.FC<PlanCardProps> = ({
plan: propPlan,
planGroup,
isCurrentPlan,
isCurrentTier: _isCurrentTier,
isDowngrade: _isDowngrade,
isUserProOrAbove,
currentLicenseInfo: _currentLicenseInfo,
currentTier: _currentTier,
onUpgradeClick,
onManageClick: _onManageClick,
loginEnabled: _loginEnabled,
}) => {
// Use plan from props, or extract from planGroup if proprietary is using it
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
const { t } = useTranslation();
if (!plan) return null; // Safety check
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
return (
<Card
key={plan.id}
padding="lg"
radius="sm"
withBorder
className="h-full w-[33%] relative"
>
{plan.popular && (
<Badge
variant="filled"
size="xs"
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
>
{t("plan.popular", "Popular")}
</Badge>
)}
<Stack gap="md" className="h-full">
<div>
<Text size="lg" fw={600}>
{plan.name}
</Text>
<Group gap="xs" align="baseline">
<Text size="2xl" fw={700}>
{plan.isContactOnly
? t("plan.customPricing", "Custom")
: `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight: string, index: number) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div className="flex-grow" />
{!shouldHideUpgrade && (
<Button
variant={
isCurrentPlan
? "filled"
: plan.isContactOnly
? "outline"
: "filled"
}
disabled={isCurrentPlan}
fullWidth
onClick={() => onUpgradeClick?.(plan)}
>
{isCurrentPlan
? t("plan.current", "Current Plan")
: plan.isContactOnly
? t("plan.contact", "Get in Touch")
: t("plan.upgrade", "Upgrade")}
</Button>
)}
</Stack>
</Card>
);
};
export default PlanCard;
@@ -151,7 +151,11 @@ function appendMcpSection(
export function createSaasConfigNavSections(
Overview: OverviewComponent,
onLogoutClick: () => void,
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
{
isDev = false,
isAnonymous = false,
t,
}: CreateSaasConfigNavSectionsOptions,
): ConfigNavSection[] {
const baseSections = createCoreConfigNavSections(false, false, false);
@@ -203,6 +207,9 @@ export function createSaasConfigNavSections(
sections = appendMcpSection(sections, t);
if (!isAnonymous) {
// The Plan tab is now the single billing surface — it internally branches
// free vs subscribed × leader vs member via useWallet(). The old separate
// "Pay-as-you-go" tab and paygEnabled / isLeader options were removed.
sections = appendBillingSection(sections, t);
}
@@ -1,63 +1,26 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useCredits } from "@app/hooks/useCredits";
import { getToolCreditCost } from "@app/utils/creditCosts";
import { openPlanSettings } from "@app/utils/appSettings";
import type { ToolId } from "@app/types/toolId";
export function useCreditCheck(operationType?: string, _endpoint?: string) {
const { hasSufficientCredits, isPro, creditBalance, refreshCredits } =
useCredits();
const { t } = useTranslation();
/**
* Pre-flight credit-balance check, formerly run before every billable tool call.
*
* Replaced by PAYG's reactive 402 FEATURE_DEGRADED handler (see paygErrorInterceptor.ts):
* we no longer try to predict whether the user has "enough credits" before the request —
* we make the request, and if the wallet hits the free-tier ceiling the BE returns 402
* with a discriminating code that the global axios interceptor turns into a toast +
* prompt-to-add-card. This is more accurate (no race between FE balance cache and the
* BE's atomic debit) and avoids the round-trip latency of a pre-flight call.
*
* The hook signature is preserved as a no-op so {@code useToolOperation} (the sole
* caller) compiles without modification. {@code checkCredits} always resolves to null
* — the BE's 402 handler is now the only gate.
*/
export function useCreditCheck(
_operationType?: string,
_endpoint?: string,
): { checkCredits: (_runtimeEndpoint?: string) => Promise<string | null> } {
const checkCredits = useCallback(
async (_runtimeEndpoint?: string): Promise<string | null> => {
const requiredCredits = getToolCreditCost(operationType as ToolId);
const creditCheck = hasSufficientCredits(requiredCredits);
if (creditBalance === null) {
try {
await refreshCredits();
} catch (_e) {
void _e;
}
return t("loadingCredits", "Checking credits...");
}
if (isPro === null) {
return t("loadingProStatus", "Checking subscription status...");
}
if (!isPro && !creditCheck.hasSufficientCredits) {
const shortfall = creditCheck.shortfall || 0;
const error = t(
"insufficientCredits",
"Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}",
{
requiredCredits,
currentBalance: creditCheck.currentBalance,
shortfall,
},
);
const notice = t(
"noticeTopUpOrPlan",
"Not enough credits, please top up or upgrade to a plan",
);
openPlanSettings(notice);
return error;
}
return null;
},
[
hasSufficientCredits,
isPro,
creditBalance,
refreshCredits,
operationType,
t,
],
async (_runtimeEndpoint?: string): Promise<string | null> => null,
[],
);
return { checkCredits };
}
@@ -0,0 +1,34 @@
/**
* Dev-only render counter. Logs every render with a stable label so we can
* eyeball excessive re-renders during PAYG Plan-page work. Counts to
* {@code window.__renderCounts[label]} so a Playwright eval can assert
* "Plan rendered ≤ 3 times after a wallet refetch."
*
* <p>In production builds {@code import.meta.env.DEV} is constant-folded to
* {@code false}, so the module-level constant {@code IS_DEV} below allows
* Vite's dead-code-elimination to drop the whole hook body — no extra refs,
* no extra renders, no window pollution.
*/
import { useRef } from "react";
declare global {
interface Window {
__renderCounts?: Record<string, number>;
}
}
const IS_DEV = import.meta.env.DEV;
export function useRenderCount(label: string): number {
// useRef must run unconditionally to satisfy rules-of-hooks. In production
// the rest of the body is dead-code-eliminated, leaving just this single
// ref allocation.
const count = useRef(0);
if (!IS_DEV) return 0;
count.current += 1;
if (typeof window !== "undefined") {
if (!window.__renderCounts) window.__renderCounts = {};
window.__renderCounts[label] = count.current;
}
return count.current;
}
+526
View File
@@ -0,0 +1,526 @@
/**
* Hook backing the PAYG Plan page. Wraps {@code GET /api/v1/payg/wallet}
* (served by {@code PaygWalletController} once Wave 1 BE lands; until then
* the dev preview route synthesises a wallet from localStorage) and exposes
* mutations for marking-subscribed and updating-the-cap.
*
* <h2>Render efficiency</h2>
*
* The hook is designed so {@code Plan}, {@code PaygFreeLeader/Member}, and
* {@code PaygLeader/Member} re-render only on actual data change:
*
* <ul>
* <li>{@link Wallet} snapshot is stored as a plain object — but every
* successful fetch deep-compares with the previous snapshot and reuses
* the prior reference if the payload is unchanged. Consumers that hold
* a stable {@code wallet} reference get stable child memoisation.
* <li>The returned {@link UseWalletResult} keeps stable callback identities
* via {@code useCallback}. {@code Plan} can pass {@code markSubscribed}
* to {@code UpgradeModal} without forcing a remount.
* <li>{@code refetch / markSubscribed / updateCap} bump an internal counter
* that the {@code useEffect} watches — no global state plumbing.
* <li>A monotonic {@code requestId} ref drops stale responses so a slow
* refetch from tick=N can't overwrite a faster one from tick=N+1
* (out-of-order resolution would otherwise show old data).
* </ul>
*
* <h2>Mutation semantics</h2>
*
* Both {@code markSubscribed} and {@code updateCap} resolve only after the
* post-mutation wallet refetch completes. So callers like the cap-editor
* "Update cap" button that gate a {@code loading} state on the returned
* promise see the UI flip exactly once the new state is visible — no
* intermediate flash of the old value.
*
* <h2>Dev preview fallback</h2>
*
* When the hook is rendered outside the saas app (e.g. on {@code
* /dev/payg-preview} during local design work) the {@code AppConfigContext}
* provider is not mounted and no backend is available. The hook detects that
* via {@code import.meta.env.DEV} + the {@code /dev/} path and falls back to
* a synthesised snapshot whose subscription state is read from
* {@code localStorage} (key {@code stirling.payg.devSubscription}). Both
* conditions are required so a production tenant whose URL happens to start
* with {@code /dev/} can't trigger the fallback.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import apiClient from "@app/services/apiClient";
import { supabase } from "@app/auth/supabase";
// ─── Public types ───────────────────────────────────────────────────────
export type WalletStatus = "free" | "subscribed";
export type WalletRole = "leader" | "member";
/**
* A single team member's billing-relevant info — name + email for the avatar
* row, {@code spendUnits} for their per-member usage display. Mirrors a row of
* the backend's {@code members} array on {@code WalletSnapshot} (joined with
* {@code team_memberships}).
*/
export interface WalletMember {
/** Supabase user id of the member. */
userId: string;
name: string;
email: string;
/** Member's current-period billable spend. */
spendUnits: number;
}
/**
* Per-category breakdown of current-period spend in billable units. The
* categories mirror the {@code FeatureGate} buckets the backend tracks:
* server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and
* pipeline / automation runs ({@code automation}). Numbers sum to {@code
* billableUsed} (modulo rounding in mock data).
*/
export interface WalletCategoryBreakdown {
api: number;
ai: number;
automation: number;
}
/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */
export interface Wallet {
/**
* The caller's primary team_id. Needed when invoking Supabase edge functions
* (create-checkout-session, etc.) that run outside Spring Security and have
* no other way to resolve the caller's team. May be null on the synthetic
* empty snapshot returned to anonymous / team-less callers.
*/
teamId: number | null;
status: WalletStatus;
role: WalletRole;
/**
* ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed;
* the calendar month for free teams.
*/
billingPeriodStart: string;
billingPeriodEnd: string;
/**
* For a free team: the one-time free documents used so far ({@code
* freeAllowance freeRemaining}). For a subscribed team: documents
* processed this month across automation + AI + API.
*/
billableUsed: number;
/**
* The team's document ceiling for the matching window: the one-time free
* grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap
* {@code floor(cap / perDocRate)} for capped subscribed teams; null when
* subscribed with no cap (uncapped).
*/
billableLimit: number | null;
/**
* The team's one-time free document grant size — the "N" in "X of N free".
* A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets
* and is not lost when the team subscribes.
*/
freeAllowance: number;
/**
* One-time free documents still available to the team
* ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* Survives subscribing — a subscribed team keeps any unused grant.
*/
freeRemaining: number;
/**
* Paid per-document rate in minor units of {@link Wallet#currency} (may be
* fractional); null when the rate can't be resolved — render "unknown",
* never substitute.
*/
pricePerDocMinor: number | null;
/** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */
currency: string | null;
/**
* Estimated charges so far this period in minor units of currency: paid
* (Stripe-metered) documents this period × rate. The free portion was
* already netted out at charge time. Informational — the Stripe invoice
* is authoritative. Null when the rate is unknown.
*/
estimatedBillMinor: number | null;
/** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */
capUsd: number | null;
/** Only meaningful when status=='subscribed'. */
noCap: boolean;
/** Stripe subscription id when subscribed; null when free. */
stripeSubscriptionId: string | null;
/** Current-period spend in billable units. */
spendUnitsThisPeriod: number;
/** Per-category spend breakdown (api / ai / automation). */
categoryBreakdown: WalletCategoryBreakdown;
/**
* Team members, populated for the leader view; empty for members or
* single-seat tenants. Leader-vs-member is still resolved via {@link
* Wallet#role} — this field just carries the per-member rows the leader's
* sub-cap table needs.
*/
members: WalletMember[];
/**
* Recent billable-activity rows. V1 returns {@code []} from the backend;
* the field exists so the Plan page can render an empty state without
* branching on undefined. Each entry is a {@code Record<string, unknown>}
* because the activity-row shape is not yet finalised — when the meter-
* event surface lands, this widens to a real interface.
*/
recent: Array<Record<string, unknown>>;
}
export interface UseWalletResult {
wallet: Wallet | null;
loading: boolean;
error: string | null;
/** Force a refetch — e.g. after Stripe redirects back into the app. */
refetch: () => Promise<void>;
/**
* Dev-only side-channel that simulates the Stripe webhook flipping the
* team to subscribed. Used by {@code UpgradeModal} when the backend is
* running the mock checkout — the real flow waits for the webhook
* instead and the next {@code refetch} picks up the change. Resolves
* once the post-mutation refetch completes.
*/
markSubscribed: (capUsd: number | null) => Promise<void>;
/**
* Update the team's monthly cap. {@code null} means "no cap". Resolves
* once the post-mutation refetch completes so a save-button
* {@code loading} state can be safely cleared on resolution.
*/
updateCap: (capUsd: number | null) => Promise<void>;
/**
* Mint a Stripe Customer Portal session and navigate to it. Calls
* the {@code create-customer-portal-session} Supabase edge function
* directly with the user's JWT (same pattern as checkout — no backend
* proxy; the function's RPC enforces team membership) and assigns
* {@code window.location} to the returned URL — same-tab redirect.
* We do not use {@code window.open(...,"_blank")} after an {@code await}
* because browsers treat it as non-user-gesture and silently popup-block
* it; the portal is a full-page experience anyway and Stripe redirects
* back to {@code return_url} on close. Throws on error so the caller can
* show a friendly toast — notably 404 {@code team_not_subscribed}.
*/
openPortal: () => Promise<void>;
}
// ─── Implementation ─────────────────────────────────────────────────────
const STORAGE_KEY = "stirling.payg.devSubscription";
/**
* Stable reference reuse — if the new payload deep-equals the previous one,
* return the previous object so React's reference check short-circuits child
* renders. Walks the top-level scalars first (cheapest), then the nested
* {@code categoryBreakdown} object, then the {@code members} array. The
* {@code recent} array is identity-compared only — Wave 1 always returns
* {@code []} so a reference-stability check is sufficient; we'll deepen
* this once the activity surface lands.
*/
function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
if (!prev) return next;
if (
prev.status !== next.status ||
prev.role !== next.role ||
prev.billingPeriodStart !== next.billingPeriodStart ||
prev.billingPeriodEnd !== next.billingPeriodEnd ||
prev.billableUsed !== next.billableUsed ||
prev.billableLimit !== next.billableLimit ||
prev.freeAllowance !== next.freeAllowance ||
prev.freeRemaining !== next.freeRemaining ||
prev.pricePerDocMinor !== next.pricePerDocMinor ||
prev.currency !== next.currency ||
prev.estimatedBillMinor !== next.estimatedBillMinor ||
prev.capUsd !== next.capUsd ||
prev.noCap !== next.noCap ||
prev.stripeSubscriptionId !== next.stripeSubscriptionId ||
prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod
) {
return next;
}
if (prev.recent.length !== next.recent.length) {
return next;
}
if (
prev.categoryBreakdown.api !== next.categoryBreakdown.api ||
prev.categoryBreakdown.ai !== next.categoryBreakdown.ai ||
prev.categoryBreakdown.automation !== next.categoryBreakdown.automation
) {
return next;
}
if (prev.members.length !== next.members.length) {
return next;
}
for (let i = 0; i < prev.members.length; i++) {
const a = prev.members[i];
const b = next.members[i];
if (
a.userId !== b.userId ||
a.name !== b.name ||
a.email !== b.email ||
a.spendUnits !== b.spendUnits
) {
return next;
}
}
// recent length-mismatch already returned `next` above; content (Wave 1 = []) is identical
// otherwise, so reuse the prior reference for stable child memoisation.
return prev;
}
/**
* Synthesise a wallet snapshot for the dev preview route. Mirrors the same
* shape the backend returns. Subscription state comes from localStorage so
* the modal's "mark subscribed" action survives a reload.
*/
function buildDevPreviewWallet(role: WalletRole): Wallet {
const subscribed =
typeof window !== "undefined" &&
(() => {
try {
return window.localStorage.getItem(STORAGE_KEY) === "subscribed";
} catch {
return false;
}
})();
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
return {
teamId: null,
status: subscribed ? "subscribed" : "free",
role,
billingPeriodStart: isoDay(periodStart),
billingPeriodEnd: isoDay(periodEnd),
billableUsed: 62,
billableLimit: subscribed ? 1250 : 500,
freeAllowance: 500,
// One-time grant: a free team has used 62 of 500 (438 left); the dev
// subscribed team is shown with its grant fully spent (kept across the
// subscribe — it just no longer gates them).
freeRemaining: subscribed ? 0 : 438,
// Free teams also carry a rate now — the backend resolves it from the
// default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid
// PDFs/month") can render before subscribing. Mirror that here.
pricePerDocMinor: 2,
currency: "usd",
estimatedBillMinor: subscribed ? 0 : null,
capUsd: subscribed ? 25 : null,
noCap: false,
stripeSubscriptionId: subscribed ? "sub_devpreview" : null,
spendUnitsThisPeriod: 62,
// Wave 1 backend (PR #6574) returns a per-category breakdown so the
// hero panel can split AI / automation / API. Use realistic but
// tier-distinguishable mock values so the dev preview shows a
// different visual when the localStorage flip toggles subscribed.
categoryBreakdown: subscribed
? { api: 12, ai: 35, automation: 15 }
: { api: 5, ai: 40, automation: 17 },
// Members are populated in the leader view by the real backend
// (joining team_memberships); the dev preview returns an empty
// array — Plan.tsx + PaygLeader still resolve role via wallet.role,
// so empty members just hides the sub-caps card.
members: [],
// Activity feed is V1 = [], the backend ships this in Wave 2 once
// payg_meter_event_log is read-accessible from the wallet endpoint.
recent: [],
};
}
/** True when we're rendered outside the real saas app (e.g. dev preview route). */
function isDevPreviewContext(): boolean {
// Both checks required: production builds drop the path check, so a real
// tenant whose URL begins with /dev/ can't accidentally hit the synthesised
// fallback.
if (!import.meta.env.DEV) return false;
if (typeof window === "undefined") return false;
return window.location.pathname.startsWith("/dev/");
}
/** Best-effort role read for dev preview — flips per query string ?role=member. */
function devPreviewRole(): WalletRole {
if (typeof window === "undefined") return "leader";
const url = new URL(window.location.href);
return url.searchParams.get("role") === "member" ? "member" : "leader";
}
export function useWallet(): UseWalletResult {
const devPreview = useRef<boolean>(isDevPreviewContext()).current;
const [wallet, setWallet] = useState<Wallet | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [refetchTick, setRefetchTick] = useState(0);
// Monotonic request id — used to discard stale responses if a faster
// refetch lands first. Only the latest issued id is permitted to commit
// its result.
const latestReqId = useRef(0);
// Promise tracking the most recent in-flight load. Mutations await this
// so their resolution semantics are "the new state is visible," not
// "the request fired." Cleared when no load is pending.
const inFlight = useRef<Promise<void> | null>(null);
useEffect(() => {
const reqId = ++latestReqId.current;
let cancelled = false;
const promise = (async () => {
setLoading(true);
setError(null);
if (devPreview) {
const synth = buildDevPreviewWallet(devPreviewRole());
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, synth));
setLoading(false);
return;
}
try {
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, res.data));
} catch (e: unknown) {
if (cancelled || reqId !== latestReqId.current) return;
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
} finally {
if (!cancelled && reqId === latestReqId.current) {
setLoading(false);
}
}
})();
inFlight.current = promise;
return () => {
cancelled = true;
// Don't clear inFlight here — let it resolve so mutations awaiting it
// still see a definitive "load completed" point. The reqId guard
// upstream ensures stale results don't commit.
};
}, [devPreview, refetchTick]);
const refetch = useCallback(async () => {
setRefetchTick((t) => t + 1);
// Snapshot the next-tick promise so the caller awaits this refetch
// specifically — the in-flight ref will be updated to it on the next
// effect run, but we can't reference that synchronously, so settle for
// a microtask handoff: await the *current* effect to flush, then await
// the new in-flight promise.
await Promise.resolve();
if (inFlight.current) {
await inFlight.current;
}
}, []);
const markSubscribed = useCallback(
async (capUsd: number | null) => {
if (devPreview) {
try {
window.localStorage.setItem(STORAGE_KEY, "subscribed");
} catch {
/* storage unavailable */
}
await refetch();
return;
}
const noCap = capUsd === null;
// The dev side-channel only exists when the BE mock service is
// running (FE-branch local dev). Once the real backend (PR #6574)
// is in play, /dev/mark-subscribed is removed and the webhook
// (customer.subscription.created) is what flips the team to
// subscribed. We swallow 404s so the modal's completion path —
// which awaits this promise before rendering the confirmation
// screen — doesn't error out on a perfectly normal "the real
// backend doesn't expose this dev hook" response. A subsequent
// refetch picks up the webhook-driven flip whenever it lands.
try {
await apiClient.post("/api/v1/payg/dev/mark-subscribed", {
capUsd: capUsd ?? 0,
noCap,
});
} catch (e: unknown) {
const status =
typeof e === "object" && e !== null && "response" in e
? (e as { response?: { status?: number } }).response?.status
: undefined;
if (status === 404) {
// Real BE in play — webhook will land the subscription
// state; log and continue. Loud-but-harmless so the dev
// notices their /dev/mark-subscribed isn't wired up.
console.info(
"[useWallet] /dev/mark-subscribed not available (404) — relying on Stripe webhook to flip subscription state",
);
} else {
throw e;
}
}
await refetch();
},
[devPreview, refetch],
);
const updateCap = useCallback(
async (capUsd: number | null) => {
const noCap = capUsd === null;
if (devPreview) {
await refetch();
return;
}
await apiClient.patch("/api/v1/payg/cap", {
capUsd: capUsd ?? 0,
noCap,
});
await refetch();
},
[devPreview, refetch],
);
const openPortal = useCallback(async () => {
if (devPreview) {
// No real Stripe in dev preview — navigate to a placeholder so the
// click still feels alive. Same-tab to match the real-flow behaviour.
window.location.assign("https://billing.stripe.com/p/login/mock");
return;
}
// Direct edge-fn invocation with the user's JWT — same pattern as
// create-checkout-session. The payg_get_checkout_context RPC inside the
// fn enforces team membership, so no backend proxy is needed; team_id
// must be a NUMBER (the fn type-checks and rejects strings).
const teamId = wallet?.teamId;
if (teamId == null) {
throw new Error("No team resolved yet");
}
const { data, error: invokeError } = await supabase.functions.invoke<{
url?: string;
error?: string;
}>("create-customer-portal-session", {
body: { team_id: teamId, return_url: window.location.href },
});
if (invokeError) {
// FunctionsHttpError etc. — the StripePortalLink caller catches and
// shows a friendly toast (404 team_not_subscribed, 403, outage).
throw invokeError;
}
if (!data?.url) {
throw new Error(data?.error ?? "Portal session response missing url");
}
// Same-tab navigation rather than window.open(...,"_blank"): Stripe's
// customer portal is a full-page experience and brings the user back
// via return_url, so a popup buys us nothing — and window.open after
// an awaited promise is treated as non-user-gesture and silently
// popup-blocked by most browsers.
window.location.assign(data.url);
}, [devPreview, wallet?.teamId]);
return {
wallet,
loading,
error,
refetch,
markSubscribed,
updateCap,
openPortal,
};
}
+30 -6
View File
@@ -3,6 +3,10 @@ import { supabase } from "@app/auth/supabase";
import { handleHttpError } from "@app/services/httpErrorHandler";
import { alert } from "@app/components/toast";
import { openPlanSettings } from "@app/utils/appSettings";
import {
classifyPaygError,
handlePaygError,
} from "@app/services/paygErrorInterceptor";
import { withBasePath } from "@app/constants/app";
// Global credit update callback - will be set by the AuthProvider
@@ -159,12 +163,6 @@ apiClient.interceptors.response.use(
);
}
}
if (response.config?.url?.includes("/api/v1/credits")) {
console.debug(
"[API Client] Credits endpoint response headers:",
response.headers,
);
}
return response;
},
async (error) => {
@@ -174,6 +172,32 @@ apiClient.interceptors.response.use(
originalRequest.url?.includes(endpoint),
);
// PAYG entitlement errors come from the EntitlementGuard on the server
// and have specific sentinels in the response body that we want to
// recognise *before* the generic 401/401-refresh logic kicks in:
//
// 402 FEATURE_DEGRADED — free-tier monthly cap exhausted; show a
// toast nudging the user to the Plan tab to upgrade.
// 401 SIGNUP_REQUIRED — anonymous user hit a billable endpoint;
// show a "Sign up to use [category]" modal instead of redirecting
// to /login (which is the default 401 behaviour). The user IS
// authenticated as anonymous — refreshing their session wouldn't
// unlock the endpoint, only signing up will.
//
// We classify the error here. If it matches either sentinel, we
// surface the appropriate UI and short-circuit the rest of the
// response interceptor so:
// - 401 SIGNUP_REQUIRED won't trigger the session-refresh / redirect-
// to-login dance below.
// - The handleHttpError() generic toast at the bottom won't fire.
// The error itself is still propagated to the caller so any
// component-level catch can react if needed.
const paygKind = classifyPaygError(error);
if (paygKind !== null) {
handlePaygError(paygKind, error);
return Promise.reject(error);
}
// On a first 401, refresh and retry — public endpoints included, since an
// expired Bearer token is rejected on any route during cold load.
if (status === 401 && !originalRequest._retry) {
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } 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 {
classifyPaygError,
extractSignupCategory,
handlePaygError,
} from "@app/services/paygErrorInterceptor";
describe("classifyPaygError", () => {
it("returns FEATURE_DEGRADED for 402 + error sentinel", () => {
const err = {
response: {
status: 402,
data: {
error: "FEATURE_DEGRADED",
missingGates: ["AUTOMATION"],
state: "DEGRADED",
periodEnd: "2026-06-30",
capUnits: 500,
spendUnits: 500,
},
},
};
expect(classifyPaygError(err)).toBe("FEATURE_DEGRADED");
});
it("returns SIGNUP_REQUIRED for 401 + error sentinel", () => {
const err = {
response: {
status: 401,
data: { error: "SIGNUP_REQUIRED", category: "AI" },
},
};
expect(classifyPaygError(err)).toBe("SIGNUP_REQUIRED");
});
it("returns null for plain 401 (session expired path is untouched)", () => {
const err = {
response: {
status: 401,
data: { error: "Unauthorized" },
},
};
expect(classifyPaygError(err)).toBeNull();
});
it("returns null for 402 without the FEATURE_DEGRADED sentinel", () => {
const err = {
response: { status: 402, data: { error: "Payment required" } },
};
expect(classifyPaygError(err)).toBeNull();
});
it("returns null when status mismatches sentinel (defence-in-depth)", () => {
// 500 + FEATURE_DEGRADED shouldn't match — server may have a bug and
// we don't want a generic 500 to silently degrade into an upgrade
// prompt. Same for 403 + SIGNUP_REQUIRED.
const a = {
response: { status: 500, data: { error: "FEATURE_DEGRADED" } },
};
const b = {
response: { status: 403, data: { error: "SIGNUP_REQUIRED" } },
};
expect(classifyPaygError(a)).toBeNull();
expect(classifyPaygError(b)).toBeNull();
});
it("returns null for malformed errors (null / non-object / no response)", () => {
expect(classifyPaygError(null)).toBeNull();
expect(classifyPaygError(undefined)).toBeNull();
expect(classifyPaygError("oops")).toBeNull();
expect(classifyPaygError({})).toBeNull();
expect(classifyPaygError({ response: null })).toBeNull();
expect(classifyPaygError({ response: { status: 402 } })).toBeNull();
expect(
classifyPaygError({ response: { status: 402, data: null } }),
).toBeNull();
expect(
classifyPaygError({ response: { status: 402, data: "bare-string" } }),
).toBeNull();
expect(
classifyPaygError({ response: { status: 402, data: { error: 123 } } }),
).toBeNull();
});
});
describe("extractSignupCategory", () => {
it("returns the category string when present", () => {
expect(
extractSignupCategory({
response: { data: { error: "SIGNUP_REQUIRED", category: "AI" } },
}),
).toBe("AI");
});
it("returns null when missing or wrong type", () => {
expect(extractSignupCategory(null)).toBeNull();
expect(extractSignupCategory({})).toBeNull();
expect(
extractSignupCategory({ response: { data: { category: 7 } } }),
).toBeNull();
expect(
extractSignupCategory({ response: { data: {} } }),
).toBeNull();
});
});
describe("handlePaygError", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows the persistent upgrade toast on FEATURE_DEGRADED", () => {
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/);
});
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);
});
it("dispatches payg:signupRequired on SIGNUP_REQUIRED with category in detail", () => {
const handler = vi.fn();
window.addEventListener("payg:signupRequired", handler);
try {
handlePaygError("SIGNUP_REQUIRED", {
response: {
status: 401,
data: { error: "SIGNUP_REQUIRED", category: "AUTOMATION" },
},
});
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);
}
});
it("dispatches with category=null when the body has no category", () => {
const handler = vi.fn();
window.addEventListener("payg:signupRequired", handler);
try {
handlePaygError("SIGNUP_REQUIRED", {
response: { status: 401, data: { error: "SIGNUP_REQUIRED" } },
});
expect(handler).toHaveBeenCalledTimes(1);
const ev = handler.mock.calls[0][0] as CustomEvent;
expect(ev.detail).toEqual({ category: null });
} finally {
window.removeEventListener("payg:signupRequired", handler);
}
});
});
@@ -0,0 +1,131 @@
/**
* 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:
*
* <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 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>
* </ul>
*
* The classifier is exported separately from the handler so unit tests can
* exercise the parsing logic without touching the toast / event side
* effects.
*/
import { alert } from "@app/components/toast";
import i18n from "@app/i18n";
import { openPlanSettings } from "@app/utils/appSettings";
/**
* 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";
/**
* Detail payload broadcast on {@code payg:signupRequired} when an anonymous
* user hits a billable endpoint. The listener (a Bootstrap component near
* the app root) opens a modal whose copy is parameterised by
* {@link #category}.
*/
export interface PaygSignupRequiredDetail {
/** Category that triggered the gate — {@code AI}, {@code AUTOMATION}, or {@code API}. */
category: string | null;
}
/**
* Inspect an axios-style error and decide whether it's one of the known
* PAYG sentinels. Returns the kind, or {@code null} if it isn't.
*
* The check is intentionally strict (status code AND body.error sentinel)
* so we don't hijack incidental 401/402 responses from other endpoints —
* notably the existing session-expired 401 flow.
*/
export function classifyPaygError(error: unknown): PaygErrorKind | null {
if (!error || typeof error !== "object") return null;
const response = (error as { response?: unknown }).response;
if (!response || typeof response !== "object") return null;
const status = (response as { status?: unknown }).status;
const data = (response as { data?: unknown }).data;
if (typeof status !== "number") return null;
if (!data || typeof data !== "object") return null;
const sentinel = (data as { error?: unknown }).error;
if (typeof sentinel !== "string") return null;
if (status === 402 && sentinel === "FEATURE_DEGRADED") {
return "FEATURE_DEGRADED";
}
if (status === 401 && sentinel === "SIGNUP_REQUIRED") {
return "SIGNUP_REQUIRED";
}
return null;
}
/** Extract {@code data.category} (a string) from an axios error, or {@code null}. */
export function extractSignupCategory(error: unknown): string | 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 category = (data as { category?: unknown }).category;
return typeof category === "string" ? category : null;
}
/**
* Surface the appropriate UI for a classified PAYG error. Toast for
* {@code FEATURE_DEGRADED}, modal-via-CustomEvent for {@code SIGNUP_REQUIRED}.
*
* 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.
*/
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",
});
return;
}
if (kind === "SIGNUP_REQUIRED") {
const category = extractSignupCategory(error);
try {
window.dispatchEvent(
new CustomEvent<PaygSignupRequiredDetail>("payg:signupRequired", {
detail: { category },
}),
);
} catch {
// SSR / test environments without a real window — no-op.
}
return;
}
}