> **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>
204 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|