Compare commits

...
Author SHA1 Message Date
Connor Yoh 72e5283633 Remove ?payg=1 demo-override hooks before merge
The PAYG settings screen is mock-only and must stay invisible to users
until the backend wallet endpoint (PR-C3.2) lands and sets
`appConfig.paygEnabled`. The demo overrides bypassed that gate via a URL
query param + localStorage, which would have let anyone with the link see
the mock data.

- AppConfigModal.tsx: drop the `paygOverride` block; `paygEnabled` and
  `isLeader` now derive only from the backend `appConfig` flags.
- proprietary/configNavSections.tsx: drop the cross-flavor `PaygLeader`
  import and the demo block that pushed PAYG into the proprietary nav.
  PAYG is saas-only.
- Payg.tsx: trim the stale comment that justified the relative CSS import
  by referencing the now-removed proprietary demo cross-import.
2026-06-05 16:19:34 +01:00
Connor Yoh d049b7523f Add missing en-GB translations for PAYG settings screen 2026-06-03 16:28:12 +01:00
Reece 058f6d601a Add Pay-as-you-go billing & usage settings screen (mock UI) 2026-06-01 13:49:10 +01:00
7 changed files with 1374 additions and 2 deletions
@@ -3065,6 +3065,10 @@ 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"
@@ -5484,6 +5488,72 @@ 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"
degradeAt = "Limit spend at"
degradeAtDesc = "Set below 100% to leave yourself headroom."
perMonth = "/ month"
preview = "{{money}} ≈ {{units}} documents per month"
previewNote = "At current pricing. Final translation happens server-side on save."
previewShort = "≈ {{units}} documents per month at current pricing."
save = "Update cap"
subtitle = "Set your maximum spend. We convert this to documents using the current price tier."
title = "Monthly spending cap"
warnAt = "Warn me at"
warnAtDesc = "Notify when usage crosses this threshold."
[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."
contactLeader = "Ask team owner to raise cap"
editCap = "Edit"
noCap = "· no sub-cap"
setCap = "Set cap"
[payg.role]
leader = "Team owner"
member = "Member"
[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.subcaps]
subtitle = "Optional: cap individual teammates so one person can't drain the team budget."
title = "Per-member sub-caps"
[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..."
billingPeriod = "Billing Period"
@@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [
"adminEndpoints",
"adminStorageSharing",
"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;
@@ -3,6 +3,7 @@ import { Modal, Button, Text, ActionIcon } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import Overview from "@app/components/shared/config/configSections/Overview";
@@ -107,14 +108,24 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
// Left navigation structure and icons
const { config: appConfig } = useAppConfig();
// PAYG settings screen is gated on the backend `appConfig.paygEnabled` flag,
// which is currently unset everywhere — the screen is mock-only and hidden
// from users until the wallet endpoint (PR-C3.2) lands.
// Until team-role lookup lands, proxy LEADER from the tenant-admin flag.
const paygEnabled = Boolean(appConfig?.paygEnabled);
const isLeader = Boolean(appConfig?.isAdmin);
const configNavSections = useMemo(
() =>
createSaasConfigNavSections(Overview, openLogoutConfirm, {
isDev,
isAnonymous,
paygEnabled,
isLeader,
t,
}),
[openLogoutConfirm, isDev, isAnonymous, t],
[openLogoutConfirm, isDev, isAnonymous, paygEnabled, isLeader, t],
);
const activeLabel = useMemo(() => {
@@ -0,0 +1,502 @@
/* 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);
}
/* ── Generic card ───────────────────────────────────────────────────── */
.payg-card {
background: var(--payg-card-bg);
border: 1px solid var(--payg-card-border);
border-radius: var(--payg-radius);
padding: 22px 24px;
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: 24px 26px;
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);
}
/* ── 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,738 @@
/**
* Pay-as-you-go billing & usage section.
*
* Design mockup for the PAYG settings screen. Mocked data, no backend wiring.
* Mirrors the framework in docs/PAYG design review:
* - doc units as the billing unit (max(pages/25, bytes/10MB))
* - customer sets cap in their own currency, backend translates to units
* via POST /api/v1/payg/cap-preview
* - wallet_policy fields: cap_units, cap_source_money, cap_source_currency,
* warn_at_pct (default 80), degrade_at_pct (default 100)
* - states: FULL → WARNED (80%) → DEGRADED (100%)
* - gates: OFFSITE_PROCESSING, AUTOMATION, AI_SUPPORT, CLIENT_SIDE
* - per-member sub-caps
*/
import React, { useMemo, useState } from "react";
import { Button, Group, NumberInput, Select, Stack, Text } from "@mantine/core";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import LockIcon from "@mui/icons-material/LockOutlined";
import CheckIcon from "@mui/icons-material/CheckRounded";
import BoltIcon from "@mui/icons-material/BoltRounded";
import LocalIcon from "@app/components/shared/LocalIcon";
// Relative (not @app/*) so the co-located CSS resolves directly.
// eslint-disable-next-line no-restricted-imports
import "./Payg.css";
import { useTranslation } from "react-i18next";
// ─── Types ────────────────────────────────────────────────────────────────
type WalletState = "FULL" | "WARNED" | "DEGRADED";
type Gate = "OFFSITE_PROCESSING" | "AUTOMATION" | "AI_SUPPORT" | "CLIENT_SIDE";
interface MemberSubCap {
userId: string;
name: string;
email: string;
capUnits: number | null; // null = no sub-cap
spendUnits: number;
}
interface RecentProcess {
id: string;
ts: string;
label: string;
// Only billable categories appear in the feed — everyday tools are free.
kind: "ai" | "automation";
docUnits: number;
}
interface PaygSnapshot {
billingPeriodStart: string; // ISO date
billingPeriodEnd: string;
spendUnits: number;
capUnits: number;
capSourceMoney: number; // minor units (cents)
capSourceCurrency: string;
/**
* Account credit in minor units — residual bought-credits / user_credits
* migrated to a positive ledger ADJUSTMENT that offsets future debits
* (design doc §11-Q8). 0 when the team has none.
*/
accountCreditMoney: number;
warnAtPct: number;
degradeAtPct: number;
state: WalletState;
enabledGates: Gate[];
members: MemberSubCap[];
recent: RecentProcess[];
stripePortalUrl: string;
}
interface PaygProps {
role: "LEADER" | "MEMBER";
}
// ─── Mock data hook (replace with real API later) ─────────────────────────
function usePaygMock(role: PaygProps["role"]): PaygSnapshot {
return useMemo(() => {
const capUnits = 2500;
const spendUnits = 312;
const pct = (spendUnits / capUnits) * 100;
const state: WalletState =
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
// Period dates relative to today so the demo doesn't show "Resets in 0 days".
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 {
billingPeriodStart: isoDay(periodStart),
billingPeriodEnd: isoDay(periodEnd),
spendUnits,
capUnits,
capSourceMoney: 5000,
capSourceCurrency: "USD",
accountCreditMoney: 1200,
warnAtPct: 80,
degradeAtPct: 100,
state,
enabledGates:
state === "DEGRADED"
? ["CLIENT_SIDE"]
: ["OFFSITE_PROCESSING", "AUTOMATION", "AI_SUPPORT", "CLIENT_SIDE"],
members:
role === "LEADER"
? [
{
userId: "u1",
name: "Alice",
email: "alice@example.com",
capUnits: null,
spendUnits: 184,
},
{
userId: "u2",
name: "Bob",
email: "bob@example.com",
capUnits: 500,
spendUnits: 128,
},
{
userId: "u3",
name: "Carol",
email: "carol@example.com",
capUnits: null,
spendUnits: 0,
},
]
: [],
recent: [
{
id: "p1",
ts: "Today 14:32",
label: "AI Create — contract summary",
kind: "ai",
docUnits: 8,
},
{
id: "p2",
ts: "Today 11:20",
label: "Auto-redaction pipeline",
kind: "automation",
docUnits: 12,
},
{
id: "p3",
ts: "Yesterday",
label: "AI-OCR — scanned batch",
kind: "ai",
docUnits: 15,
},
{
id: "p4",
ts: "2 days ago",
label: "Nightly compress automation",
kind: "automation",
docUnits: 6,
},
],
stripePortalUrl: "https://billing.stripe.com/p/login/mock",
};
}, [role]);
}
// ─── Helpers ──────────────────────────────────────────────────────────────
const CURRENCY_SYMBOL: Record<string, string> = {
USD: "$",
GBP: "£",
EUR: "€",
CNY: "¥",
INR: "₹",
};
// 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 formatMoney(minor: number, currency: string): string {
const major = minor / 100;
const sym = CURRENCY_SYMBOL[currency] ?? "";
return `${sym}${major.toFixed(2)}`;
}
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)",
);
}
}
// ─── Hero usage panel ───────────────────────────────────────────────────────
function UsageHero({ snap }: { snap: PaygSnapshot }) {
const { t } = useTranslation();
const pct = Math.min(100, (snap.spendUnits / snap.capUnits) * 100);
const stateLabel = {
FULL: t("payg.state.full", "Healthy"),
WARNED: t("payg.state.warned", "Approaching cap"),
DEGRADED: t("payg.state.degraded", "Cap reached"),
}[snap.state];
const periodEnd = new Date(snap.billingPeriodEnd);
const daysLeft = Math.max(
0,
Math.ceil((periodEnd.getTime() - Date.now()) / 86_400_000),
);
return (
<div className="payg-hero" data-state={snap.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">
{snap.spendUnits.toLocaleString()}
</span>
<span className="payg-hero__cap">
/ {snap.capUnits.toLocaleString()}{" "}
{t("payg.usage.docs", "documents")}
</span>
</div>
</div>
<div className="payg-status" data-state={snap.state}>
<span className="payg-status__dot" />
{stateLabel}
</div>
</Group>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={snap.state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="payg-hero__meta">
<span>
{t("payg.usage.spent", "{{spend}} of {{cap}} used", {
spend: formatMoney(
(snap.capSourceMoney * snap.spendUnits) / snap.capUnits,
snap.capSourceCurrency,
),
cap: formatMoney(snap.capSourceMoney, snap.capSourceCurrency),
})}
</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>
{snap.accountCreditMoney > 0 && (
<span className="payg-hero__credit">
{t("payg.usage.credit", "{{amount}} account credit", {
amount: formatMoney(
snap.accountCreditMoney,
snap.capSourceCurrency,
),
})}
</span>
)}
</div>
</div>
</div>
);
}
// ─── Cap editor ─────────────────────────────────────────────────────────────
function CapEditor({ snap }: { snap: PaygSnapshot }) {
const { t } = useTranslation();
// Local edit state — real impl would call POST /api/v1/payg/cap-preview on change
const [money, setMoney] = useState<number>(snap.capSourceMoney / 100);
const [currency, setCurrency] = useState<string>(snap.capSourceCurrency);
const [warnAt, setWarnAt] = useState<number>(snap.warnAtPct);
const [degradeAt, setDegradeAt] = useState<number>(snap.degradeAtPct);
// Mock preview — real impl reads stripe.prices.tiers via Sync Engine
const previewUnits = Math.round(
(money * 100 * snap.capUnits) / snap.capSourceMoney,
);
const dirty =
money !== snap.capSourceMoney / 100 ||
currency !== snap.capSourceCurrency ||
warnAt !== snap.warnAtPct ||
degradeAt !== snap.degradeAtPct;
return (
<div className="payg-card">
<Stack gap="lg">
<div>
<div className="payg-card__title">
{t("payg.cap.title", "Monthly spending cap")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.cap.subtitle",
"Set your maximum spend. We convert this to documents using the current price tier.",
)}
</div>
</div>
<Group align="flex-end" gap="sm" wrap="nowrap">
<NumberInput
label={t("payg.cap.amount", "Cap amount")}
value={money}
onChange={(v) => setMoney(typeof v === "number" ? v : 0)}
min={0}
step={5}
decimalScale={2}
fixedDecimalScale
prefix={CURRENCY_SYMBOL[currency] ?? ""}
size="md"
style={{ flex: 1 }}
/>
<Select
label={t("payg.cap.currency", "Currency")}
value={currency}
onChange={(v) => setCurrency(v ?? "USD")}
// V1 launch currencies per design doc §11-Q9 (USD/EUR/GBP).
// Adding a fourth is one Stripe Price + a config update, no code change.
data={[
{ value: "USD", label: "USD ($)" },
{ value: "EUR", label: "EUR (€)" },
{ value: "GBP", label: "GBP (£)" },
]}
size="md"
w={150}
/>
<Text size="sm" pb={10} c="dimmed">
{t("payg.cap.perMonth", "/ month")}
</Text>
</Group>
<div className="payg-preview">
<span className="payg-preview__icon">
<BoltIcon sx={{ fontSize: 20 }} />
</span>
<div>
<div className="payg-preview__main">
{t(
"payg.cap.preview",
"{{money}} ≈ {{units}} documents per month",
{
money: `${CURRENCY_SYMBOL[currency] ?? ""}${money.toFixed(2)}`,
units: previewUnits.toLocaleString(),
},
)}
</div>
<div className="payg-preview__note">
{t(
"payg.cap.previewNote",
"At current pricing. Final translation happens server-side on save.",
)}
</div>
</div>
</div>
<Group grow align="flex-start">
<NumberInput
label={t("payg.cap.warnAt", "Warn me at")}
description={t(
"payg.cap.warnAtDesc",
"Notify when usage crosses this threshold.",
)}
value={warnAt}
onChange={(v) => setWarnAt(typeof v === "number" ? v : 80)}
min={0}
max={100}
suffix=" %"
/>
<NumberInput
label={t("payg.cap.degradeAt", "Limit spend at")}
description={t(
"payg.cap.degradeAtDesc",
"Set below 100% to leave yourself headroom.",
)}
value={degradeAt}
onChange={(v) => setDegradeAt(typeof v === "number" ? v : 100)}
min={Math.max(1, warnAt)}
max={100}
suffix=" %"
/>
</Group>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
size="xs"
disabled={!dirty}
onClick={() => {
setMoney(snap.capSourceMoney / 100);
setCurrency(snap.capSourceCurrency);
setWarnAt(snap.warnAtPct);
setDegradeAt(snap.degradeAtPct);
}}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
variant="default"
size="xs"
disabled={!dirty}
leftSection={<LocalIcon icon="check-rounded" />}
>
{t("payg.cap.save", "Update cap")}
</Button>
</Group>
</Stack>
</div>
);
}
function CapReadOnly({ snap }: { snap: PaygSnapshot }) {
const { t } = useTranslation();
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}>
{formatMoney(snap.capSourceMoney, snap.capSourceCurrency)}
</Text>
<Text c="dimmed">{t("payg.cap.perMonth", "/ month")}</Text>
</Group>
<Text size="sm" c="dimmed">
{t(
"payg.cap.previewShort",
"≈ {{units}} documents per month at current pricing.",
{ units: snap.capUnits.toLocaleString() },
)}
</Text>
<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>
<Button mt={8} size="xs" variant="light">
{t("payg.member.contactLeader", "Ask team owner to raise cap")}
</Button>
</div>
</div>
</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 },
];
function GatesCard() {
const { t } = useTranslation();
return (
<div className="payg-card">
<Stack gap="md">
<div>
<div className="payg-card__title">
{t("payg.gates.title", "What happens when the cap is reached")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.gates.subtitle",
"Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised.",
)}
</div>
</div>
<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>
</Stack>
</div>
);
}
// ─── Member sub-caps ────────────────────────────────────────────────────────
function MemberSubCaps({ snap }: { snap: PaygSnapshot }) {
const { t } = useTranslation();
return (
<div className="payg-card">
<Stack gap="sm">
<div>
<div className="payg-card__title">
{t("payg.subcaps.title", "Per-member sub-caps")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.subcaps.subtitle",
"Optional: cap individual teammates so one person can't drain the team budget.",
)}
</div>
</div>
<div>
{snap.members.map((m) => {
const subPct =
m.capUnits && m.capUnits > 0
? Math.min(100, (m.spendUnits / m.capUnits) * 100)
: null;
return (
<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()}
{m.capUnits !== null ? (
<> / {m.capUnits.toLocaleString()}</>
) : (
<Text span size="xs" c="dimmed">
{" "}
{t("payg.member.noCap", "· no sub-cap")}
</Text>
)}
</div>
{subPct !== null && (
<div className="payg-member__minibar">
<div
className="payg-member__minibar-fill"
style={{ width: `${subPct}%` }}
/>
</div>
)}
</div>
<Button size="xs" variant="default">
{m.capUnits === null
? t("payg.member.setCap", "Set cap")
: t("payg.member.editCap", "Edit")}
</Button>
</div>
);
})}
</div>
</Stack>
</div>
);
}
// ─── Activity feed ──────────────────────────────────────────────────────────
function ActivityFeed({ snap }: { snap: PaygSnapshot }) {
const { t } = useTranslation();
return (
<div className="payg-card">
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<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>
<Button variant="default" size="xs" style={{ flexShrink: 0 }}>
{t("payg.activity.viewAll", "View all")}
</Button>
</Group>
<div>
{snap.recent.map((r) => (
<div className="payg-activity-row" key={r.id}>
<span className="payg-activity__dot" data-kind={r.kind} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="payg-activity__label">{r.label}</div>
<div className="payg-activity__ts">{r.ts}</div>
</div>
<span className="payg-activity__kind">{r.kind}</span>
<span className="payg-activity__units">
{r.docUnits} {t("payg.activity.units", "units")}
</span>
</div>
))}
</div>
</Stack>
</div>
);
}
// ─── Stripe CTA ──────────────────────────────────────────────────────────────
function StripePortalLink({ snap }: { snap: PaygSnapshot }) {
const { t } = useTranslation();
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
component="a"
href={snap.stripePortalUrl}
target="_blank"
rel="noopener noreferrer"
rightSection={<OpenInNewIcon sx={{ fontSize: 16 }} />}
variant="light"
>
{t("payg.stripe.open", "Open billing portal")}
</Button>
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────
const Payg: React.FC<PaygProps> = ({ role }) => {
const { t } = useTranslation();
const snap = usePaygMock(role);
const isLeader = role === "LEADER";
const fmt = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
return (
<div className="payg">
<Stack gap="lg">
{/* The modal chrome already renders the section title ("Billing &
usage"), so we lead with the descriptive subtitle + role pill. */}
<Group justify="space-between" align="center" wrap="nowrap">
<div className="payg-header__subtitle">
{t(
"payg.subtitle",
"Pay-as-you-go — you only pay for what you process. Billing period {{start}} {{end}}.",
{
start: fmt(snap.billingPeriodStart),
end: fmt(snap.billingPeriodEnd),
},
)}
</div>
<span className="payg-role-pill" data-leader={isLeader}>
{isLeader
? t("payg.role.leader", "Team owner")
: t("payg.role.member", "Member")}
</span>
</Group>
<UsageHero snap={snap} />
{isLeader ? <CapEditor snap={snap} /> : <CapReadOnly snap={snap} />}
<GatesCard />
{isLeader && snap.members.length > 0 && <MemberSubCaps snap={snap} />}
<ActivityFeed snap={snap} />
{isLeader && <StripePortalLink snap={snap} />}
</Stack>
</div>
);
};
export default Payg;
// Convenience exports for the config nav to render either variant directly.
export const PaygLeader: React.FC = () => <Payg role="LEADER" />;
export const PaygMember: React.FC = () => <Payg role="MEMBER" />;
@@ -9,12 +9,28 @@ import GeneralSection from "@app/components/shared/config/configSections/General
import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import Plan from "@app/components/shared/config/configSections/Plan";
import {
PaygLeader,
PaygMember,
} from "@app/components/shared/config/configSections/Payg";
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
interface CreateSaasConfigNavSectionsOptions {
isDev?: boolean;
isAnonymous?: boolean;
/**
* Show the new Pay-as-you-go billing section. Gated on
* `appConfig.paygEnabled` so the tenant has to be opted in.
*/
paygEnabled?: boolean;
/**
* Whether the viewer is the team owner (LEADER) — controls whether the PAYG
* screen shows editable cap/sub-cap controls or a read-only member view.
* Until team roles land we proxy this from `appConfig.isAdmin`; replace with
* a real team-role lookup when available.
*/
isLeader?: boolean;
t: TFunction<"translation", undefined>;
}
@@ -108,10 +124,40 @@ function appendBillingSection(
];
}
function appendPaygSection(
sections: ConfigNavSection[],
t: TFunction<"translation", undefined>,
isLeader: boolean,
): ConfigNavSection[] {
if (sections.some((s) => s.items.some((i) => i.key === "payg"))) {
return sections;
}
return [
...sections,
{
title: t("config.payg.section", "Pay-as-you-go"),
items: [
{
key: "payg",
label: t("config.payg.label", "Billing & usage"),
icon: "speed-rounded",
component: isLeader ? <PaygLeader /> : <PaygMember />,
},
],
},
];
}
export function createSaasConfigNavSections(
Overview: OverviewComponent,
onLogoutClick: () => void,
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
{
isDev = false,
isAnonymous = false,
paygEnabled = false,
isLeader = false,
t,
}: CreateSaasConfigNavSectionsOptions,
): ConfigNavSection[] {
const baseSections = createCoreConfigNavSections(false, false, false);
@@ -154,6 +200,9 @@ export function createSaasConfigNavSections(
if (!isAnonymous) {
sections = appendBillingSection(sections, t);
if (paygEnabled) {
sections = appendPaygSection(sections, t, isLeader);
}
}
if (isDev) {