Files
Stirling-PDF/frontend/shared/billing/SpendCapControl.tsx
T
14245d33d1 feat(saas): account-link — connected self-hosted billing (Mode A) [WIP, flag-gated] (#6738)
> **Draft / WIP.** Combined-billing **Mode A** (connected self-hosted).
Entirely behind `stirling.billing.account-link.enabled` (default **off**
→ beans absent → 404). Pairs with Stirling-PDF-SaaS PR #313 (twin
migration → `v3`).

## What this does

A self-hosted instance links a SaaS account in the **Portal**, gets a
**device credential**, and authenticates unattended metering/entitlement
with it — no long-lived user JWT on the server. The Portal then surfaces
the team's **billing** (free trial → metered Processor plan) driven by
the live wallet.

```mermaid
sequenceDiagram
  participant Portal as Portal (browser)
  participant Supa as SaaS Supabase Auth
  participant Local as Self-hosted backend
  participant SaaS as SaaS Java (app/saas)
  Portal->>Supa: signIn / signUp (Supabase JS, short-lived JWT)
  Supa-->>Portal: JWT (SDK-refreshed, stays in browser)
  Portal->>Local: hand JWT (same-origin)
  Local->>SaaS: POST /account-link/register (Bearer JWT, leader)
  SaaS-->>Local: { device_id, device_secret }  (secret once)
  Note over Local: store device_secret server-side
  loop unattended
    Local->>SaaS: /api/v1/instance/** (X-Device-Id + X-Device-Secret)
    SaaS-->>Local: entitlement / gate decision
  end
```

**Auth model:** human auth = Supabase JS (ephemeral JWT, kept for
attended portal features). Durable instance auth = a team-bound
**device_id + secret** (SHA-256 stored, shown once), non-user
`ROLE_LINKED_INSTANCE`, path-scoped to `/api/v1/instance/**`. Instance
binds to a **team**, never a user.

## Billing surface (Portal · Mode A states)

`Usage & billing` is state-driven by the link/subscription dimension and
built to the marketing designs, sharing one component layer across
states:

- **Unlinked** → link-account prompt.
- **Linked · Free** — the *Processor trial*: a one-time 500-PDF free
grant ("Process 500 PDFs free, then $X/PDF"), the team's free-editor
fleet, and a leader-only **Switch on the Processor →** (embedded Stripe
Checkout).
- **Linked · Subscribed** — the *Processor plan* dashboard:
PDFs-processed split (API / Agents / Automation), **spend this month**
vs. a **spend limit** meter with a run-rate projection and an **in-place
cap editor** (preset buckets + suggested value + guardrail), Stripe
**invoices** (with billed PDFs per invoice), and the default **payment
method**. Card / subscription changes deep-link to Stripe's hosted
portal.

Manual PDF editing is always free — only Automation / AI / API is
metered; a `$0` cap blocks all metered work (≠ "no cap").

**Shared, not duplicated:** the editor-fleet card, the Enterprise
upsell, and the meter (`@shared/billing` `MeterBar`) render in both the
free and subscribed views; money/cap math lives once in
`@shared/billing`. The page header is a sticky, full-bleed bar.

**New SaaS reads** (defensive — degrade to empty/"—" when the Stripe
mirror lacks a table, never 500):
- `GET /api/v1/payg/payment-method` — default card (brand / last4 /
expiry) from `stripe.payment_methods`.
- Invoice **PDFs processed** — billed line-item quantity from
`stripe.invoice_line_items`.

## Progress

- [x] Schema: `V22 linked_instance` (+ Supabase twin in #313)
- [x] `AccountLinkController` register / list / revoke (leader-only,
team from caller)
- [x] Device-credential filter (path-scoped, constant-time,
revocation-aware) + `SupabaseSecurityConfig` wiring (conditional)
- [x] `GET /api/v1/instance/whoami` + **`/entitlement`** (reuses
`EntitlementService`/`TeamBillingService`) + tests
- [x] Self-hosted backend (`app/proprietary`): orchestrator + instance
gate (dark + **fail-open**) + tests
- [x] Portal: in-app Supabase login modal + register hand-off +
`LinkContext` (unlinked default) + "Linked instances" view — all
`@shared` Storybook components
- [x] **Portal billing surface** — free (Processor trial) + subscribed
(Processor plan) Usage views to marketing spec; link-state derived from
the **live wallet**; in-place cap editor; over-cap banner
- [x] **SaaS reads** — payment-method endpoint + invoice billed-units
(defensive `stripe.*` mirror DAOs) + tests
- [x] Orphan guard: block leaving/accepting away from a team whose
departure orphans its linked instances
- [ ] Metering Step 2 (lease + reconcile loop) + bounded fail-open
cutoff
- [ ] Proprietary hardening (SaaS base-url config, secret-at-rest, finer
billable classification) + HTTP integration test
- [ ] Cross-repo Stripe lifecycle certified end-to-end (subscribe →
meter → cancel → 402)
- [ ] Admin ⟺ SaaS-leader enforcement (separate portal-team-mgmt
workstream)

## Verification — all green
| Gate | Result |
|---|---|
| `STIRLING_FLAVOR=saas :saas:test` | BUILD SUCCESSFUL (account-link +
payg, incl. `PaygPaymentMethodControllerTest`,
`PaygInvoicesControllerTest`) |
| `:proprietary:test` | BUILD SUCCESSFUL (account-link + entitlement
cache/interceptor) |
| portal | tsc 0 · eslint 0 · **vitest 55** · storybook build (all
billing stories) |
| frontend post-sync | typecheck shared + portal + editor (saas +
desktop): 0 |

## Screenshots — billing UI
_Latest Storybook renders (Portal/Billing). Drag each capture below its
caption — kept out of the repo._

**Linked · Free — Processor trial**


<img width="1648" height="503" alt="01-free-processor-trial"
src="https://github.com/user-attachments/assets/afe6238a-d3b4-47fd-8ea2-cbaed8b0a653"
/>

**Linked · Subscribed — Processor plan dashboard**

<img width="1648" height="930" alt="02-subscribed-processor-plan"
src="https://github.com/user-attachments/assets/329e6808-a9a9-4e65-99af-5a8a5e6bf4ab"
/>

**Spend limit — in-place cap editor**

<img width="1648" height="411" alt="03-spend-limit-editor"
src="https://github.com/user-attachments/assets/acc95096-bf8e-4ab0-a32c-3c20dc94f816"
/>


## Review feedback applied
Reworked the portal after first-pass feedback: linking signs in via the
**shared Supabase login** (SSO + email/password) — no bespoke form; the
**device secret is never shown in or sent to the FE** (the local backend
registers + stores it server-side); billing copy reads **PDFs**, not
"units"; the wallet surface uses **`@shared` components** matching the
SaaS Plan page. Re-verified including an assertion the link response
carries no `deviceSecret`/`deviceId`.

**Synced onto unified auth + in-app login (2026-06-23).** Merged `main`
incl. **#6725 unified auth** (`frontend/shared/auth`); the link flow
uses a shared `useSupabaseLogin` hook + `SupabaseLoginForm`, a portal
`LinkAccountModal`, and `useAccountLink.completeLink(session)` (+
on-mount SSO redirect-return). Config: `VITE_SAAS_SUPABASE_URL` +
`VITE_SAAS_SUPABASE_ANON_KEY`. The local `/account-link/link` call
carries the Spring admin bearer with the SaaS JWT in the body. **SSO**
needs the SaaS Supabase project to allow-list the portal redirect URL
(email/password works without it).

## Assumptions / open
- **Proprietary remains a scaffold** (placeholder SaaS base-url,
plaintext device secret at rest, coarse billable classification).
- Payment-method + invoice-quantity render only when
`stripe.payment_methods` / `stripe.invoice_line_items` are in the
Sync-Engine target (confirm in the Supabase/Sync-Engine config);
otherwise they degrade gracefully.
- A self-contained local HTML report + manual E2E runbook live in
`notes/account-link-report/` (dev artifacts, outside the repo).

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-29 13:35:07 +00:00

204 lines
6.3 KiB
TypeScript

import React, { useEffect, useState } from "react";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
import { Button } from "@shared/components";
import {
DEFAULT_CAP_PRESETS,
currencySymbol,
docCapForMoney,
formatMinor,
} from "@shared/billing/format";
/** Copy the control renders. The editor passes i18n strings; the portal uses the defaults. */
export interface SpendCapControlLabels {
custom: string;
amountAria: string;
noCap: string;
save: string;
docsEstimate: (docs: string) => string;
docsRate: (rate: string) => string;
noCapDesc: string;
}
const DEFAULT_LABELS: SpendCapControlLabels = {
custom: "Custom",
amountAria: "Cap amount",
noCap: "No cap",
save: "Update cap",
docsEstimate: (docs) => `≈ ${docs} processed PDFs / month`,
docsRate: (rate) => `at ${rate} / PDF`,
noCapDesc:
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
};
export interface SpendCapControlProps {
/** Current cap in major currency units; null = no cap, 0 = a real $0 cap. Controlled. */
capUsd: number | null;
onChange: (capUsd: number | null) => void;
/** Per-document rate in minor units; null/0 hides the estimate. */
pricePerDocMinor?: number | null;
currency?: string | null;
presets?: readonly number[];
/** When provided, renders the inline Save button. */
onSave?: (capUsd: number | null) => Promise<void> | void;
/** Persisted value to diff against for the dirty check (with {@link onSave}). */
savedCapUsd?: number | null;
/** Disable all inputs (e.g. while a parent operation is in flight). */
disabled?: boolean;
/** Quiet helper line under the estimate. */
note?: React.ReactNode;
labels?: Partial<SpendCapControlLabels>;
}
/**
* Monthly spend-cap control shared by the editor cloud surface and the admin
* portal: preset chips, a custom-entry pill, a no-cap chip, an optional Save
* button, and a live cap→PDF estimate. Fully controlled (capUsd + onChange).
* Styling comes from each app's own {@code scc-*} CSS; copy is injected via
* {@link labels} so this carries no i18n dependency.
*/
export function SpendCapControl({
capUsd,
onChange,
pricePerDocMinor,
currency,
presets = DEFAULT_CAP_PRESETS,
onSave,
savedCapUsd,
disabled,
note,
labels,
}: SpendCapControlProps) {
const L = { ...DEFAULT_LABELS, ...labels };
const [saving, setSaving] = useState(false);
const sym = currencySymbol(currency);
const isNoCap = capUsd === null;
const customActive = capUsd != null && !presets.includes(capUsd);
// Local mirror of the custom field's text so partial entry isn't clobbered by
// the controlled value. Parents that need it to resync (e.g. after a save)
// remount the control via a key.
const [customText, setCustomText] = useState<string>(
customActive ? String(capUsd) : "",
);
// Resync the field to an externally-loaded custom cap — e.g. the wallet arrives
// after first render (capUsd null/preset -> 1234), which would otherwise leave the
// field blank since customText only seeds once at mount. Gated on !focused so it
// never clobbers what the user is actively typing.
const [focused, setFocused] = useState(false);
useEffect(() => {
if (!focused && customActive && String(capUsd) !== customText) {
setCustomText(String(capUsd));
}
}, [capUsd, customActive, focused, customText]);
const previewDocs = docCapForMoney(capUsd, pricePerDocMinor);
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
const busy = saving || disabled;
const selectPreset = (preset: number) => {
setCustomText("");
onChange(preset);
};
const selectNoCap = () => {
setCustomText("");
onChange(null);
};
const onCustomInput = (raw: string) => {
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={capUsd === preset ? "true" : "false"}
onClick={() => selectPreset(preset)}
disabled={busy}
>
{sym}
{preset.toLocaleString()}
</button>
))}
<label
className="scc-custom"
data-active={customActive ? "true" : "false"}
>
<span className="scc-custom__symbol">{sym}</span>
<input
className="scc-custom__input"
inputMode="numeric"
value={customActive ? customText : ""}
placeholder={L.custom}
aria-label={L.amountAria}
onChange={(e) => onCustomInput(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={busy}
/>
</label>
<button
type="button"
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
data-selected={isNoCap ? "true" : "false"}
onClick={selectNoCap}
disabled={busy}
>
{L.noCap}
</button>
{onSave && (
<div className="scc-row__spacer">
<Button
variant="outline"
size="sm"
loading={saving}
disabled={!dirty || busy}
onClick={handleSave}
>
{L.save}
</Button>
</div>
)}
</div>
{previewDocs != null && (
<div className="scc-estimate">
<DescriptionIcon
className="scc-estimate__icon"
sx={{ fontSize: 22 }}
/>
<div>
<div className="scc-estimate__main">
{L.docsEstimate(previewDocs.toLocaleString())}
</div>
<div className="scc-estimate__sub">
{L.docsRate(formatMinor(pricePerDocMinor ?? 0, currency))}
</div>
</div>
</div>
)}
{isNoCap && <div className="scc-note">{L.noCapDesc}</div>}
{note && <div className="scc-note">{note}</div>}
</div>
);
}