From 14245d33d12d931808f3cffc6f3aba7a0b58ea93 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:35:07 +0100 Subject: [PATCH] =?UTF-8?q?feat(saas):=20account-link=20=E2=80=94=20connec?= =?UTF-8?q?ted=20self-hosted=20billing=20(Mode=20A)=20[WIP,=20flag-gated]?= =?UTF-8?q?=20(#6738)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **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** 01-free-processor-trial **Linked · Subscribed — Processor plan dashboard** 02-subscribed-processor-plan **Spend limit — in-place cap editor** 03-spend-limit-editor ## 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 --- .taskfiles/backend.yml | 4 + app/.env.proprietary | 8 + app/.gitignore | 1 + .../accountlink/AccountLinkClient.java | 265 +++++ .../accountlink/AccountLinkController.java | 88 ++ .../accountlink/AccountLinkProperties.java | 39 + .../accountlink/AccountLinkService.java | 92 ++ .../accountlink/AccountLinkWebMvcConfig.java | 36 + .../BillableOperationClassifier.java | 38 + .../accountlink/DeviceCredential.java | 53 + .../DeviceCredentialRepository.java | 15 + .../accountlink/DeviceCredentialStore.java | 55 + .../accountlink/EntitlementCache.java | 124 +++ .../accountlink/EntitlementState.java | 19 + .../proprietary/accountlink/GateDecision.java | 34 + .../accountlink/InstanceEntitlement.java | 19 + .../accountlink/InstanceEntitlementGate.java | 104 ++ .../InstanceEntitlementInterceptor.java | 65 ++ .../configuration/DatabaseConfig.java | 2 + .../accountlink/AccountLinkClientTest.java | 192 ++++ .../AccountLinkControllerTest.java | 68 ++ .../accountlink/AccountLinkServiceTest.java | 111 ++ .../BillableOperationClassifierTest.java | 49 + .../accountlink/EntitlementCacheTest.java | 114 ++ .../InstanceEntitlementGateTest.java | 117 ++ .../InstanceEntitlementGateWiringTest.java | 71 ++ .../InstanceEntitlementInterceptorTest.java | 61 ++ .../accountlink/AccountLinkController.java | 161 +++ .../saas/accountlink/AccountLinkService.java | 117 ++ .../DeviceCredentialAuthenticationFilter.java | 108 ++ .../saas/accountlink/InstanceController.java | 131 +++ .../saas/accountlink/LinkedInstance.java | 77 ++ .../LinkedInstanceAuthenticationToken.java | 45 + .../accountlink/LinkedInstanceRepository.java | 43 + .../software/saas/config/SaasJpaConfig.java | 2 + .../saas/payg/api/PaygInvoicesController.java | 150 +++ .../payg/api/PaygPaymentMethodController.java | 108 ++ .../software/saas/payg/cap/CapEvaluator.java | 16 +- .../saas/payg/stripe/StripeInvoiceDao.java | 215 ++++ .../payg/stripe/StripePaymentMethodDao.java | 91 ++ .../saas/security/SupabaseSecurityConfig.java | 17 +- .../saas/service/SaasTeamService.java | 34 +- .../saas/V24__account_link_instances.sql | 44 + .../AccountLinkControllerTest.java | 169 +++ .../accountlink/AccountLinkServiceTest.java | 102 ++ ...iceCredentialAuthenticationFilterTest.java | 162 +++ .../accountlink/InstanceControllerTest.java | 190 ++++ .../payg/api/PaygInvoicesControllerTest.java | 189 ++++ .../api/PaygPaymentMethodControllerTest.java | 184 ++++ .../saas/payg/cap/CapEvaluatorTest.java | 18 +- .../SupabaseSecurityConfigMoreTest.java | 11 +- .../saas/service/SaasTeamServiceTest.java | 92 ++ frontend/.gitignore | 8 +- frontend/.storybook/main.ts | 10 + frontend/.storybook/preview.tsx | 55 +- .../config/configSections/SpendCapControl.tsx | 255 +---- .../config/configSections/usageMeters.tsx | 127 +-- frontend/editor/src/cloud/hooks/useWallet.ts | 133 +-- .../src/saas/routes/authShared/saas-auth.css | 52 - frontend/portal/.env | 19 + .../public/locales/en-US/translation.toml | 429 +++++--- frontend/portal/src/App.tsx | 84 +- frontend/portal/src/ViewRouter.tsx | 2 + frontend/portal/src/api/agents.ts | 4 +- frontend/portal/src/api/assistant.ts | 15 +- frontend/portal/src/api/billing.ts | 79 ++ frontend/portal/src/api/docs.ts | 6 +- frontend/portal/src/api/documents.ts | 4 +- frontend/portal/src/api/editorDeploy.ts | 4 +- frontend/portal/src/api/home.ts | 16 +- frontend/portal/src/api/http.test.ts | 131 +++ frontend/portal/src/api/http.ts | 175 ++- frontend/portal/src/api/infrastructure.ts | 24 +- frontend/portal/src/api/link.test.ts | 123 +++ frontend/portal/src/api/link.ts | 85 ++ frontend/portal/src/api/notifications.ts | 6 +- frontend/portal/src/api/ops.ts | 18 +- frontend/portal/src/api/pipelines.ts | 6 +- frontend/portal/src/api/policies.ts | 24 +- frontend/portal/src/api/sdkComponents.ts | 4 +- frontend/portal/src/api/search.ts | 4 +- frontend/portal/src/api/settings.ts | 4 +- frontend/portal/src/api/sources.ts | 22 +- frontend/portal/src/api/usage.ts | 44 - frontend/portal/src/api/users.ts | 6 +- frontend/portal/src/auth/saasSupabase.ts | 42 + .../portal/src/auth/saasSupabaseLogin.test.ts | 98 ++ .../src/billing/sharedBillingFormat.test.ts | 104 ++ frontend/portal/src/billing/stripe.test.ts | 114 ++ frontend/portal/src/billing/stripe.ts | 170 +++ frontend/portal/src/components/AppShell.css | 12 +- frontend/portal/src/components/Header.tsx | 6 +- .../portal/src/components/SettingsModal.tsx | 43 +- frontend/portal/src/components/Sidebar.tsx | 28 +- .../account-link/AccountLinkPanel.tsx | 120 +++ .../account-link/LinkAccountCard.stories.tsx | 55 + .../account-link/LinkAccountCard.tsx | 81 ++ .../account-link/LinkAccountModal.tsx | 107 ++ .../account-link/LinkGate.stories.tsx | 24 + .../src/components/account-link/LinkGate.tsx | 43 + .../LinkedInstancesTable.stories.tsx | 36 + .../account-link/LinkedInstancesTable.tsx | 123 +++ .../billing/EnterpriseUpsell.stories.tsx | 17 + .../components/billing/EnterpriseUpsell.tsx | 39 + .../billing/FreePdfEditorsCard.stories.tsx | 18 + .../components/billing/FreePdfEditorsCard.tsx | 76 ++ .../billing/FreePlanView.stories.tsx | 22 + .../src/components/billing/FreePlanView.tsx | 111 ++ .../src/components/billing/InvoicesList.tsx | 223 ++++ .../billing/LinkAccountPrompt.stories.tsx | 14 + .../components/billing/LinkAccountPrompt.tsx | 27 + .../billing/PaymentMethodCard.stories.tsx | 51 + .../components/billing/PaymentMethodCard.tsx | 91 ++ .../billing/PdfsProcessedCard.stories.tsx | 27 + .../components/billing/PdfsProcessedCard.tsx | 104 ++ .../billing/SpendLimitCard.stories.tsx | 67 ++ .../src/components/billing/SpendLimitCard.tsx | 279 +++++ .../billing/SpendThisMonthCard.stories.tsx | 15 + .../components/billing/SpendThisMonthCard.tsx | 47 + .../billing/StripeCheckoutModal.tsx | 152 +++ .../billing/SubscribedPlanView.stories.tsx | 85 ++ .../components/billing/SubscribedPlanView.tsx | 106 ++ .../billing/WalletMeter.stories.tsx | 28 + .../src/components/billing/WalletMeter.tsx | 70 ++ .../portal/src/components/billing/billing.css | 999 ++++++++++++++++++ .../src/components/billing/walletFixtures.ts | 60 ++ frontend/portal/src/components/icons.tsx | 9 + .../usage/AvailablePlans.stories.tsx | 17 - .../src/components/usage/AvailablePlans.tsx | 38 - .../usage/BillingHistoryTable.stories.tsx | 41 - .../components/usage/BillingHistoryTable.tsx | 140 --- .../usage/BillingKpiStrip.stories.tsx | 34 - .../src/components/usage/BillingKpiStrip.tsx | 91 -- .../usage/CurrentPlanCard.stories.tsx | 48 - .../src/components/usage/CurrentPlanCard.tsx | 194 ---- .../src/components/usage/PlanCard.stories.tsx | 31 - .../portal/src/components/usage/PlanCard.tsx | 68 -- .../usage/SpendCapControl.stories.tsx | 41 - .../src/components/usage/SpendCapControl.tsx | 121 --- .../components/usage/UpgradeModal.stories.tsx | 28 - .../src/components/usage/UpgradeModal.tsx | 137 --- .../components/usage/UsageChart.stories.tsx | 46 - .../src/components/usage/UsageChart.tsx | 51 - .../portal/src/components/usage/format.ts | 15 - .../src/contexts/AccountLinkContext.tsx | 37 + .../portal/src/contexts/LinkContext.test.tsx | 71 ++ frontend/portal/src/contexts/LinkContext.tsx | 119 +++ frontend/portal/src/contexts/TierContext.tsx | 54 +- frontend/portal/src/contexts/UIContext.tsx | 77 +- .../portal/src/hooks/useAccountLink.test.tsx | 75 ++ frontend/portal/src/hooks/useAccountLink.ts | 142 +++ frontend/portal/src/hooks/useStripePortal.ts | 34 + frontend/portal/src/mocks/agents.ts | 2 +- frontend/portal/src/mocks/docs.ts | 2 +- frontend/portal/src/mocks/documents.ts | 2 +- frontend/portal/src/mocks/editorDeploy.ts | 2 +- frontend/portal/src/mocks/handlers/index.ts | 4 +- frontend/portal/src/mocks/handlers/link.ts | 65 ++ frontend/portal/src/mocks/handlers/usage.ts | 35 - frontend/portal/src/mocks/home.ts | 2 +- frontend/portal/src/mocks/infrastructure.ts | 2 +- frontend/portal/src/mocks/link.test.ts | 72 ++ frontend/portal/src/mocks/link.ts | 147 +++ frontend/portal/src/mocks/pipelines.ts | 2 +- frontend/portal/src/mocks/policies.ts | 2 +- frontend/portal/src/mocks/sdkComponents.ts | 2 +- frontend/portal/src/mocks/settings.ts | 8 +- frontend/portal/src/mocks/usage.ts | 341 ------ frontend/portal/src/mocks/users.ts | 2 +- frontend/portal/src/views/AccountLink.css | 125 +++ frontend/portal/src/views/Usage.css | 70 +- frontend/portal/src/views/Usage.tsx | 272 +++-- frontend/portal/src/views/Users.tsx | 14 +- frontend/portal/src/vite-env.d.ts | 8 + frontend/shared/auth/ui/OAuthButtons.tsx | 39 +- frontend/shared/auth/ui/SupabaseLoginForm.tsx | 89 ++ frontend/shared/auth/ui/auth.css | 52 + frontend/shared/auth/ui/useSupabaseLogin.ts | 134 +++ frontend/shared/billing/MeterBar.tsx | 68 ++ frontend/shared/billing/SpendCapControl.tsx | 203 ++++ frontend/shared/billing/format.ts | 102 ++ frontend/shared/billing/index.ts | 24 + frontend/shared/billing/types.ts | 67 ++ 183 files changed, 11473 insertions(+), 2477 deletions(-) create mode 100644 app/.env.proprietary create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/EntitlementCacheTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql create mode 100644 app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java create mode 100644 frontend/portal/src/api/billing.ts create mode 100644 frontend/portal/src/api/http.test.ts create mode 100644 frontend/portal/src/api/link.test.ts create mode 100644 frontend/portal/src/api/link.ts delete mode 100644 frontend/portal/src/api/usage.ts create mode 100644 frontend/portal/src/auth/saasSupabase.ts create mode 100644 frontend/portal/src/auth/saasSupabaseLogin.test.ts create mode 100644 frontend/portal/src/billing/sharedBillingFormat.test.ts create mode 100644 frontend/portal/src/billing/stripe.test.ts create mode 100644 frontend/portal/src/billing/stripe.ts create mode 100644 frontend/portal/src/components/account-link/AccountLinkPanel.tsx create mode 100644 frontend/portal/src/components/account-link/LinkAccountCard.stories.tsx create mode 100644 frontend/portal/src/components/account-link/LinkAccountCard.tsx create mode 100644 frontend/portal/src/components/account-link/LinkAccountModal.tsx create mode 100644 frontend/portal/src/components/account-link/LinkGate.stories.tsx create mode 100644 frontend/portal/src/components/account-link/LinkGate.tsx create mode 100644 frontend/portal/src/components/account-link/LinkedInstancesTable.stories.tsx create mode 100644 frontend/portal/src/components/account-link/LinkedInstancesTable.tsx create mode 100644 frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx create mode 100644 frontend/portal/src/components/billing/EnterpriseUpsell.tsx create mode 100644 frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx create mode 100644 frontend/portal/src/components/billing/FreePdfEditorsCard.tsx create mode 100644 frontend/portal/src/components/billing/FreePlanView.stories.tsx create mode 100644 frontend/portal/src/components/billing/FreePlanView.tsx create mode 100644 frontend/portal/src/components/billing/InvoicesList.tsx create mode 100644 frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx create mode 100644 frontend/portal/src/components/billing/LinkAccountPrompt.tsx create mode 100644 frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx create mode 100644 frontend/portal/src/components/billing/PaymentMethodCard.tsx create mode 100644 frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx create mode 100644 frontend/portal/src/components/billing/PdfsProcessedCard.tsx create mode 100644 frontend/portal/src/components/billing/SpendLimitCard.stories.tsx create mode 100644 frontend/portal/src/components/billing/SpendLimitCard.tsx create mode 100644 frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx create mode 100644 frontend/portal/src/components/billing/SpendThisMonthCard.tsx create mode 100644 frontend/portal/src/components/billing/StripeCheckoutModal.tsx create mode 100644 frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx create mode 100644 frontend/portal/src/components/billing/SubscribedPlanView.tsx create mode 100644 frontend/portal/src/components/billing/WalletMeter.stories.tsx create mode 100644 frontend/portal/src/components/billing/WalletMeter.tsx create mode 100644 frontend/portal/src/components/billing/billing.css create mode 100644 frontend/portal/src/components/billing/walletFixtures.ts delete mode 100644 frontend/portal/src/components/usage/AvailablePlans.stories.tsx delete mode 100644 frontend/portal/src/components/usage/AvailablePlans.tsx delete mode 100644 frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx delete mode 100644 frontend/portal/src/components/usage/BillingHistoryTable.tsx delete mode 100644 frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx delete mode 100644 frontend/portal/src/components/usage/BillingKpiStrip.tsx delete mode 100644 frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx delete mode 100644 frontend/portal/src/components/usage/CurrentPlanCard.tsx delete mode 100644 frontend/portal/src/components/usage/PlanCard.stories.tsx delete mode 100644 frontend/portal/src/components/usage/PlanCard.tsx delete mode 100644 frontend/portal/src/components/usage/SpendCapControl.stories.tsx delete mode 100644 frontend/portal/src/components/usage/SpendCapControl.tsx delete mode 100644 frontend/portal/src/components/usage/UpgradeModal.stories.tsx delete mode 100644 frontend/portal/src/components/usage/UpgradeModal.tsx delete mode 100644 frontend/portal/src/components/usage/UsageChart.stories.tsx delete mode 100644 frontend/portal/src/components/usage/UsageChart.tsx delete mode 100644 frontend/portal/src/components/usage/format.ts create mode 100644 frontend/portal/src/contexts/AccountLinkContext.tsx create mode 100644 frontend/portal/src/contexts/LinkContext.test.tsx create mode 100644 frontend/portal/src/contexts/LinkContext.tsx create mode 100644 frontend/portal/src/hooks/useAccountLink.test.tsx create mode 100644 frontend/portal/src/hooks/useAccountLink.ts create mode 100644 frontend/portal/src/hooks/useStripePortal.ts create mode 100644 frontend/portal/src/mocks/handlers/link.ts delete mode 100644 frontend/portal/src/mocks/handlers/usage.ts create mode 100644 frontend/portal/src/mocks/link.test.ts create mode 100644 frontend/portal/src/mocks/link.ts delete mode 100644 frontend/portal/src/mocks/usage.ts create mode 100644 frontend/portal/src/views/AccountLink.css create mode 100644 frontend/shared/auth/ui/SupabaseLoginForm.tsx create mode 100644 frontend/shared/auth/ui/useSupabaseLogin.ts create mode 100644 frontend/shared/billing/MeterBar.tsx create mode 100644 frontend/shared/billing/SpendCapControl.tsx create mode 100644 frontend/shared/billing/format.ts create mode 100644 frontend/shared/billing/index.ts create mode 100644 frontend/shared/billing/types.ts diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 2be287bd0f..77c9645476 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -30,6 +30,10 @@ tasks: dev:proprietary: desc: "Start backend dev server in proprietary mode" + # `dotenv:` reads from the root Taskfile's directory (".") because this + # subtaskfile is included with `dir: .`. Local overrides in + # .env.proprietary.local win over the committed .env.proprietary defaults. + dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary'] ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' diff --git a/app/.env.proprietary b/app/.env.proprietary new file mode 100644 index 0000000000..4e30318e57 --- /dev/null +++ b/app/.env.proprietary @@ -0,0 +1,8 @@ +# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary +# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored). + +# Combined-billing account link (Mode A). Feature-flagged: OFF until release. +# Flip to true in app/.env.proprietary.local to test linking locally. +STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false +# SaaS base URL the linked instance calls (register + entitlement). +STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app diff --git a/app/.gitignore b/app/.gitignore index e2a86ce4cf..f7e1a1575a 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,3 +1,4 @@ # Whitelist committed env defaults. `.env.saas.local` (and any other .env*) # stays ignored via the root .gitignore. !.env.saas +!.env.proprietary diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java new file mode 100644 index 0000000000..a77f67f8a8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -0,0 +1,265 @@ +package stirling.software.proprietary.accountlink; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode + * A"). + * + *

Two calls: + * + *

+ * + *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see + * {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS + * endpoint. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkClient { + + static final String HEADER_DEVICE_ID = "X-Device-Id"; + static final String HEADER_DEVICE_SECRET = "X-Device-Secret"; + + private final AccountLinkProperties properties; + private final ObjectMapper mapper; + private final HttpClient httpClient; + + @Autowired + public AccountLinkClient(AccountLinkProperties properties, ObjectMapper mapper) { + this( + properties, + mapper, + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(properties.getRequestTimeoutSeconds())) + .build()); + } + + /** Package-private: lets tests inject a stub {@link HttpClient}. */ + AccountLinkClient( + AccountLinkProperties properties, ObjectMapper mapper, HttpClient httpClient) { + this.properties = properties; + this.mapper = mapper; + this.httpClient = httpClient; + } + + /** The device credential a successful {@link #register} returns. */ + public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {} + + /** + * A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can + * map auth failures (401/403) through rather than masking everything as a 502. + */ + public static class UpstreamException extends IOException { + private final int status; + + public UpstreamException(int status, String body) { + super("SaaS account-link returned HTTP " + status + ": " + body); + this.status = status; + } + + public int status() { + return status; + } + } + + /** + * Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked + * or invalid. Distinct from a transport/server failure (which returns {@code null} and fails + * open): the cache must BLOCK billable work on this rather than serve a stale entitled + * snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport + * try/catch. + */ + public static final class RevokedException extends RuntimeException { + private final int status; + + public RevokedException(int status) { + super("SaaS entitlement denied (credential revoked/invalid): HTTP " + status); + this.status = status; + } + + public int status() { + return status; + } + } + + /** + * Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted + * credential. + * + * @throws IOException on transport failure or a non-2xx response (caller surfaces to the + * admin). + */ + public RegisterResult register(String supabaseJwt, String instanceName) throws IOException { + String body = + instanceName == null || instanceName.isBlank() + ? "{}" + : "{\"name\":" + mapper.writeValueAsString(instanceName) + "}"; + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/account-link/register")) + .header("Authorization", "Bearer " + supabaseJwt) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .timeout(timeout()) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse response = send(request); + if (response.statusCode() / 100 != 2) { + throw new UpstreamException(response.statusCode(), response.body()); + } + JsonNode root = mapper.readTree(response.body()); + String deviceId = text(root, "deviceId"); + String deviceSecret = text(root, "deviceSecret"); + if (deviceId == null || deviceSecret == null) { + throw new IOException("SaaS register response missing deviceId/deviceSecret"); + } + Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null; + return new RegisterResult(deviceId, deviceSecret, teamId); + } + + /** + * Revokes this instance's own credential on the SaaS side ({@code POST + * /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is + * allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable + * or rejects the call, so the caller (local unlink) can still clear locally and log the orphan + * row for follow-up. Idempotent on SaaS (already-revoked → still 204). + */ + public boolean revokeSelf(String deviceId, String deviceSecret) { + try { + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/instance/revoke-self")) + .header(HEADER_DEVICE_ID, deviceId) + .header(HEADER_DEVICE_SECRET, deviceSecret) + .header("Accept", "application/json") + .timeout(timeout()) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + HttpResponse response = send(request); + if (response.statusCode() / 100 != 2) { + log.debug("Self-revoke returned HTTP {}", response.statusCode()); + return false; + } + return true; + } catch (Exception e) { + log.debug("Self-revoke failed: {}", e.getMessage()); + return false; + } + } + + /** + * Fetches the current entitlement using the stored device credential. Three outcomes: + * + *

+ */ + public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) { + HttpResponse response; + try { + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/instance/entitlement")) + .header(HEADER_DEVICE_ID, deviceId) + .header(HEADER_DEVICE_SECRET, deviceSecret) + .header("Accept", "application/json") + .timeout(timeout()) + .GET() + .build(); + response = send(request); + } catch (Exception e) { + // Transport failure (timeout / connection refused / interrupted) → unknown, fail open. + log.debug("Entitlement fetch failed: {}", e.getMessage()); + return null; + } + int status = response.statusCode(); + if (status == 401 || status == 403) { + // Authoritative deny — the SaaS side rejected the credential (revoked/invalid). + throw new RevokedException(status); + } + if (status / 100 != 2) { + // Server / transient error → unknown, fail open (do NOT treat as a deny). + log.debug("Entitlement fetch returned HTTP {}", status); + return null; + } + try { + return parseEntitlement(response.body()); + } catch (IOException e) { + log.debug("Entitlement parse failed: {}", e.getMessage()); + return null; + } + } + + private InstanceEntitlement parseEntitlement(String body) throws IOException { + JsonNode root = mapper.readTree(body); + boolean subscribed = root.path("subscribed").asBoolean(false); + long freeRemaining = root.path("freeRemainingUnits").asLong(0); + long periodSpend = root.path("periodSpendUnits").asLong(0); + Long periodCap = + root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null; + EntitlementState state = mapState(root.path("state").asText(null)); + return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state); + } + + /** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */ + private static EntitlementState mapState(String raw) { + if (raw == null) { + return EntitlementState.UNKNOWN; + } + return switch (raw) { + case "OK", "ACTIVE", "SUBSCRIBED", "FREE" -> EntitlementState.OK; + case "OVER_LIMIT", "PAYG_LIMIT_REACHED", "BLOCKED" -> EntitlementState.OVER_LIMIT; + default -> EntitlementState.UNKNOWN; + }; + } + + private HttpResponse send(HttpRequest request) throws IOException { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted calling SaaS account-link", e); + } + } + + private URI uri(String path) { + String base = properties.getSaasBaseUrl().strip().replaceAll("/+$", ""); + return URI.create(base + path); + } + + private Duration timeout() { + return Duration.ofSeconds(properties.getRequestTimeoutSeconds()); + } + + private static String text(JsonNode node, String field) { + return node.hasNonNull(field) ? node.get(field).asText() : null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java new file mode 100644 index 0000000000..1d1a8aa77d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -0,0 +1,88 @@ +package stirling.software.proprietary.accountlink; + +import java.io.IOException; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +/** + * Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A"). + * + *

The portal (served from this same origin, admin authenticated by the existing self-hosted + * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS + * backend, which mints + returns a device credential we store locally. {@code GET /status} backs + * the portal's link card. + * + *

Admin-only, {@code @Profile("!saas")}, gated behind {@code + * stirling.billing.account-link.enabled} — off → bean absent → 404. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/account-link") +@Profile("!saas") +@PreAuthorize("hasRole('ADMIN')") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkController { + + private final AccountLinkService service; + + public AccountLinkController(AccountLinkService service) { + this.service = service; + } + + /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ + public record LinkRequest(String supabaseJwt, String name) {} + + @PostMapping("/link") + public ResponseEntity link(@RequestBody LinkRequest req) { + if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) { + return ResponseEntity.badRequest() + .body(java.util.Map.of("error", "supabaseJwt is required")); + } + try { + return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name())); + } catch (AccountLinkClient.UpstreamException e) { + // Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so + // the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the + // raw upstream body back to the browser. + HttpStatus status = + e.status() == HttpStatus.UNAUTHORIZED.value() + || e.status() == HttpStatus.FORBIDDEN.value() + ? HttpStatus.valueOf(e.status()) + : HttpStatus.BAD_GATEWAY; + log.warn("Account-link register rejected upstream: HTTP {}", e.status()); + return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED")); + } catch (IOException e) { + // Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the + // configured SaaS host/IP. Log it server-side; return the same opaque body the + // UpstreamException branch does. + log.warn("Account-link failed (transport): {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "LINK_FAILED")); + } + } + + @GetMapping("/status") + public ResponseEntity status() { + return ResponseEntity.ok(service.status()); + } + + @PostMapping("/unlink") + public ResponseEntity unlink() { + service.unlink(); + return ResponseEntity.noContent().build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java new file mode 100644 index 0000000000..c12e56f06d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -0,0 +1,39 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +/** + * Self-hosted side of combined-billing "Mode A" (connected self-hosted). + * + *

Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag + * the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional + * code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is + * off by default and dark — when off nothing gates and the link endpoints 404. + */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "stirling.billing.account-link") +public class AccountLinkProperties { + + /** Master switch. When {@code false} (default) the feature is fully inert. */ + private boolean enabled = false; + + /** + * Base URL of the SaaS backend this instance links to (register + entitlement live there). + * + *

STUB: defaults to the public cloud host; an operator overrides it for staging. There is no + * existing SaaS-base-url property in the self-hosted profile, so this is introduced here. + */ + private String saasBaseUrl = "https://stirling.com/app"; + + /** Cached entitlement is reused for this long before a refresh is attempted. */ + private long entitlementCacheSeconds = 300; + + /** Connect/read timeout for the outbound SaaS calls. */ + private int requestTimeoutSeconds = 10; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java new file mode 100644 index 0000000000..1bb27d9cd6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java @@ -0,0 +1,92 @@ +package stirling.software.proprietary.accountlink; + +import java.io.IOException; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * Linking orchestrator (self-hosted side of combined-billing "Mode A"). + * + *

{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase + * JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest. + * The credential — not the JWT — authenticates all later unattended entitlement calls. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkService { + + private final AccountLinkClient client; + private final DeviceCredentialStore credentialStore; + private final EntitlementCache entitlementCache; + + public AccountLinkService( + AccountLinkClient client, + DeviceCredentialStore credentialStore, + EntitlementCache entitlementCache) { + this.client = client; + this.credentialStore = credentialStore; + this.entitlementCache = entitlementCache; + } + + /** Status of this instance's link, for the portal's "Account link" card. */ + public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {} + + /** + * Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the + * credential. + * + * @throws IOException if the SaaS register call fails (surfaced to the admin as a link error). + */ + public LinkStatus link(String supabaseJwt, String instanceName) throws IOException { + AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName); + credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId()); + entitlementCache.invalidate(); + log.info("Account-link: instance linked to team {}", result.teamId()); + return status(); + } + + /** + * Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code + * revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear + * still proceeds (admin's intent must win); the orphan row can be revoked from the portal. + */ + public void unlink() { + credentialStore + .get() + .ifPresent( + c -> { + boolean ok = client.revokeSelf(c.getDeviceId(), c.getDeviceSecret()); + if (!ok) { + log.warn( + "Account-link: SaaS self-revoke failed for device {};" + + " clearing locally anyway (admin can revoke" + + " from the portal).", + c.getDeviceId()); + } + }); + credentialStore.clear(); + entitlementCache.invalidate(); + log.info("Account-link: instance unlinked"); + } + + public LinkStatus status() { + Optional cred = credentialStore.get(); + return cred.map( + c -> + new LinkStatus( + true, + c.getDeviceId(), + c.getTeamId(), + c.getLinkedAt() != null + ? c.getLinkedAt().toString() + : null)) + .orElseGet(() -> new LinkStatus(false, null, null, null)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java new file mode 100644 index 0000000000..8c20abfa11 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Registers the account-link entitlement gate. Path patterns cover the billable API surface; the + * interceptor itself re-checks billability (and short-circuits manual tools), but scoping here + * keeps the gate off the bulk of interactive endpoints entirely. + * + *

Whole config is gated behind {@code stirling.billing.account-link.enabled} + + * {@code @Profile("!saas")}; absent when off, so no interceptor is registered. + */ +@Configuration +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkWebMvcConfig implements WebMvcConfigurer { + + private final InstanceEntitlementInterceptor gateInterceptor; + + public AccountLinkWebMvcConfig(InstanceEntitlementInterceptor gateInterceptor) { + this.gateInterceptor = gateInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual + // calls be gated too, while the interceptor lets genuine manual tools through. + registry.addInterceptor(gateInterceptor) + .addPathPatterns("/api/v1/**") + .excludePathPatterns("/api/v1/account-link/**"); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java new file mode 100644 index 0000000000..136e4c3214 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.accountlink; + +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.common.service.InternalApiClient; + +/** + * Classifies a request as billable (AI / automation) or free (a manual tool). + * + *

Mirrors the saas billing categorisation at a coarse level, without depending on the saas + * module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation + * marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy + * sub-steps). Everything else — interactive manual PDF tools — is always free. + */ +public final class BillableOperationClassifier { + + private static final String AI_PATH_PREFIX = "/api/v1/ai/"; + + private BillableOperationClassifier() {} + + public static boolean isBillable(HttpServletRequest request) { + if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) { + return true; + } + String uri = request.getRequestURI(); + if (uri == null) { + return false; + } + // Prefix-match the AI surface (not a loose substring contains), stripping a deployment + // context path so //api/v1/ai/** still classifies as billable. + String ctx = request.getContextPath(); + String path = + ctx != null && !ctx.isEmpty() && uri.startsWith(ctx) + ? uri.substring(ctx.length()) + : uri; + return path.startsWith(AI_PATH_PREFIX); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java new file mode 100644 index 0000000000..4625572310 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java @@ -0,0 +1,53 @@ +package stirling.software.proprietary.accountlink; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * The device credential this self-hosted instance received when it linked a SaaS account + * (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team. + * + *

Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code + * deviceSecret} so it can present it on every unattended entitlement call. It lives in the local + * database (the same store that already holds API-key material and the license signature), so it is + * as secure-at-rest as the rest of the instance's secrets. + */ +@Entity +@Table(name = "account_link_device_credential") +@NoArgsConstructor +@Getter +@Setter +public class DeviceCredential implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final Long SINGLETON_ID = 1L; + + @Id + @Column(name = "id") + private Long id = SINGLETON_ID; + + /** Public identifier minted by the SaaS register call; sent as {@code X-Device-Id}. */ + @Column(name = "device_id", nullable = false, length = 64) + private String deviceId; + + /** High-entropy secret returned once by register; sent as {@code X-Device-Secret}. */ + @Column(name = "device_secret", nullable = false, length = 128) + private String deviceSecret; + + /** SaaS team this instance is linked to; informational on the instance side. */ + @Column(name = "team_id") + private Long teamId; + + @Column(name = "linked_at", nullable = false) + private LocalDateTime linkedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java new file mode 100644 index 0000000000..792731e589 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.accountlink; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface DeviceCredentialRepository extends JpaRepository { + + /** The singleton credential, if this instance has linked. */ + default Optional findCredential() { + return findById(DeviceCredential.SINGLETON_ID); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java new file mode 100644 index 0000000000..33658a48c6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java @@ -0,0 +1,55 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Secure-at-rest persistence for this instance's device credential. Thin wrapper over the + * singleton-row repository so the rest of the feature never touches JPA directly. + * + *

Gated + {@code @Profile("!saas")}: only the self-hosted profile links outward to a SaaS team. + */ +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class DeviceCredentialStore { + + private final DeviceCredentialRepository repo; + + public DeviceCredentialStore(DeviceCredentialRepository repo) { + this.repo = repo; + } + + @Transactional(readOnly = true) + public Optional get() { + return repo.findCredential(); + } + + @Transactional(readOnly = true) + public boolean isLinked() { + return repo.findCredential().isPresent(); + } + + /** Persists (or replaces) the credential returned by a SaaS register call. */ + @Transactional + public void save(String deviceId, String deviceSecret, Long teamId) { + DeviceCredential cred = repo.findCredential().orElseGet(DeviceCredential::new); + cred.setId(DeviceCredential.SINGLETON_ID); + cred.setDeviceId(deviceId); + cred.setDeviceSecret(deviceSecret); + cred.setTeamId(teamId); + cred.setLinkedAt(LocalDateTime.now()); + repo.save(cred); + } + + /** Unlinks this instance locally (idempotent). */ + @Transactional + public void clear() { + repo.findCredential().ifPresent(repo::delete); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java new file mode 100644 index 0000000000..63818c1f98 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java @@ -0,0 +1,124 @@ +package stirling.software.proprietary.accountlink; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on + * every billable request. Single-slot (one instance = one linked team), TTL-based. + * + *

Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it + * has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has + * ever been fetched and the latest refresh failed (the gate treats empty as "unknown → + * allow"). + * + *

But an AUTHORITATIVE deny (revoked/invalid credential → {@link + * AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a + * {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately + * rather than serving a stale entitled snapshot. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class EntitlementCache { + + private final DeviceCredentialStore credentialStore; + private final AccountLinkClient client; + private final Duration ttl; + + /** Entitlement + fetch time, swapped atomically as one value so readers never tear. */ + private record Snapshot(InstanceEntitlement entitlement, Instant fetchedAt) {} + + private static final Snapshot EMPTY = new Snapshot(null, Instant.EPOCH); + + /** Blocked entitlement synthesised on an authoritative deny (revoked/invalid credential). */ + private static final InstanceEntitlement REVOKED = + new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED); + + private volatile Snapshot snapshot = EMPTY; + + /** Single-flight guard: one thread refreshes while others serve the current snapshot. */ + private final AtomicBoolean refreshing = new AtomicBoolean(false); + + public EntitlementCache( + DeviceCredentialStore credentialStore, + AccountLinkClient client, + AccountLinkProperties properties) { + this.credentialStore = credentialStore; + this.client = client; + this.ttl = Duration.ofSeconds(properties.getEntitlementCacheSeconds()); + } + + /** + * Current entitlement, refreshing if stale. {@link Optional#empty()} means "unknown" — either + * not linked or the SaaS side is unreachable and we have no prior snapshot. + */ + public Optional current() { + // Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS + // call) while concurrent callers serve the last snapshot — no thundering herd of + // synchronous round-trips on the billable hot path. Safe because the gate fails open. + if (isStale(snapshot) && refreshing.compareAndSet(false, true)) { + try { + refresh(); + } finally { + refreshing.set(false); + } + } + return Optional.ofNullable(snapshot.entitlement()); + } + + private boolean isStale(Snapshot snap) { + // fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed + // fetch backs off for a full TTL instead of every billable request re-triggering a + // blocking round-trip against a dead/slow SaaS endpoint. + return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0; + } + + /** + * Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but + * still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny + * (revoked credential) replaces it with a blocked snapshot so the gate stops billable work. + */ + void refresh() { + Optional cred = credentialStore.get(); + if (cred.isEmpty()) { + // Unlinked: clear any stale snapshot so the gate sees "not linked". + snapshot = new Snapshot(null, Instant.now()); + return; + } + try { + InstanceEntitlement fresh = + client.fetchEntitlement(cred.get().getDeviceId(), cred.get().getDeviceSecret()); + if (fresh != null) { + snapshot = new Snapshot(fresh, Instant.now()); + } else { + // Unreachable / server error: keep the last known entitlement (may be null) but + // stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime. + log.debug( + "Entitlement refresh failed; reusing last known snapshot, backing off a TTL"); + snapshot = new Snapshot(snapshot.entitlement(), Instant.now()); + } + } catch (AccountLinkClient.RevokedException e) { + // Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately + // rather than serving the stale entitled snapshot until the next unlink. + log.info( + "Entitlement denied (HTTP {}); blocking billable work for the revoked credential", + e.status()); + snapshot = new Snapshot(REVOKED, Instant.now()); + } + } + + /** Forces a refresh on the next {@link #current()} (e.g. right after linking). */ + public void invalidate() { + snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java new file mode 100644 index 0000000000..1cca34cb69 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.accountlink; + +/** + * Coarse entitlement state the local gate enforces against. Proprietary-local (no coupling to the + * saas billing module): the SaaS entitlement response is parsed into this minimal shape. + */ +public enum EntitlementState { + /** Within free pool or covered by an active subscription — billable work allowed. */ + OK, + /** Free pool exhausted and no subscription / over the period cap — billable work blocked. */ + OVER_LIMIT, + /** + * Device credential revoked/invalid on the SaaS side (authoritative 401/403 deny) — billable + * work blocked. Synthesised locally by {@code EntitlementCache}, never sent by SaaS. + */ + REVOKED, + /** Unrecognised/malformed reply — the gate falls back to its numeric checks, not this flag. */ + UNKNOWN +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java new file mode 100644 index 0000000000..677183278b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.accountlink; + +/** + * Outcome of {@link InstanceEntitlementGate}. {@link #allowed} is what the interceptor enforces; + * {@link #reason} carries the machine-readable signal the FE maps to a prompt (e.g. "link to + * activate"). Manual-tool and fail-open allows carry an informational reason but never block. + */ +public record GateDecision(boolean allowed, Reason reason) { + + public enum Reason { + /** Feature flag is off — gate is fully inert. */ + FLAG_OFF, + /** Operation is a manual tool — always free, never gated. */ + MANUAL_FREE, + /** Linked + within entitlement — billable work allowed. */ + ENTITLED, + /** Entitlement source unreachable — fail open, allow. */ + FAIL_OPEN, + /** Not linked — block billable work; FE should prompt to link. */ + NOT_LINKED, + /** Linked but over the limit / no subscription — block billable work. */ + OVER_LIMIT, + /** Credential revoked/invalid on the SaaS side — block billable work. */ + REVOKED + } + + public static GateDecision allow(Reason reason) { + return new GateDecision(true, reason); + } + + public static GateDecision block(Reason reason) { + return new GateDecision(false, reason); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java new file mode 100644 index 0000000000..6445d886e9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.accountlink; + +/** + * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response — + * just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no + * saas types. + * + * @param subscribed team has an active subscription + * @param freeRemainingUnits remaining free-pool units (>0 means free work is available) + * @param periodSpendUnits paid units spent this period + * @param periodCapUnits paid cap for the period; {@code null} = uncapped + * @param state coarse state classification (see {@link EntitlementState}) + */ +public record InstanceEntitlement( + boolean subscribed, + long freeRemainingUnits, + long periodSpendUnits, + Long periodCapUnits, + EntitlementState state) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java new file mode 100644 index 0000000000..c975bad055 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -0,0 +1,104 @@ +package stirling.software.proprietary.accountlink; + +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +/** + * Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance. + * + *

Rules (in order): + * + *

    + *
  1. Flag off → always allow (feature inert). + *
  2. Manual tool → always allow (manual tools are free, never metered). + *
  3. Billable + not linked → block with {@code NOT_LINKED} ("link to activate"). + *
  4. Billable + linked + entitlement unknown (unreachable) → fail open, allow. + *
  5. Billable + linked + entitled → allow. + *
  6. Billable + linked + credential revoked → block with {@code REVOKED}. + *
  7. Billable + linked + over limit → block with {@code OVER_LIMIT}. + *
+ * + *

The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the + * live flag / linked-state / entitlement. This is the unit-tested core. + */ +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class InstanceEntitlementGate { + + private final AccountLinkProperties properties; + private final DeviceCredentialStore credentialStore; + private final EntitlementCache entitlementCache; + + public InstanceEntitlementGate( + AccountLinkProperties properties, + DeviceCredentialStore credentialStore, + EntitlementCache entitlementCache) { + this.properties = properties; + this.credentialStore = credentialStore; + this.entitlementCache = entitlementCache; + } + + /** Evaluates the gate for a request, resolving live state from the store + cache. */ + public GateDecision evaluate(boolean billable) { + if (!properties.isEnabled()) { + return GateDecision.allow(GateDecision.Reason.FLAG_OFF); + } + if (!billable) { + return GateDecision.allow(GateDecision.Reason.MANUAL_FREE); + } + boolean linked = credentialStore.isLinked(); + Optional entitlement = + linked ? entitlementCache.current() : Optional.empty(); + return decide(true, true, linked, entitlement); + } + + /** + * Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown" + * (unreachable): when linked, that fails open. + */ + public static GateDecision decide( + boolean flagEnabled, + boolean billable, + boolean linked, + Optional entitlement) { + if (!flagEnabled) { + return GateDecision.allow(GateDecision.Reason.FLAG_OFF); + } + if (!billable) { + return GateDecision.allow(GateDecision.Reason.MANUAL_FREE); + } + if (!linked) { + return GateDecision.block(GateDecision.Reason.NOT_LINKED); + } + if (entitlement.isEmpty()) { + // Linked but entitlement source unreachable — never hard-block billable work on our + // inability to reach billing. + return GateDecision.allow(GateDecision.Reason.FAIL_OPEN); + } + InstanceEntitlement e = entitlement.get(); + if (e.state() == EntitlementState.REVOKED) { + // Credential revoked/invalid (authoritative deny) — block, distinct from over-limit. + return GateDecision.block(GateDecision.Reason.REVOKED); + } + return entitled(e) + ? GateDecision.allow(GateDecision.Reason.ENTITLED) + : GateDecision.block(GateDecision.Reason.OVER_LIMIT); + } + + /** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */ + private static boolean entitled(InstanceEntitlement e) { + if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) { + return false; + } + if (e.subscribed()) { + // Subscribed: allowed unless a period cap is set and exceeded. + return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits(); + } + // Unsubscribed: only the free pool covers billable work. + return e.freeRemainingUnits() > 0; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java new file mode 100644 index 0000000000..8597813a85 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -0,0 +1,65 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +/** + * Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and + * blocks it when the instance is unlinked or over its limit; manual tools pass straight through. + * + *

Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code + * {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to + * activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit + * responses). Fail-open and flag-off both let the request continue. + * + *

Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link + * AccountLinkWebMvcConfig} never registers it, so there is no per-request cost. + */ +@Slf4j +@Component +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class InstanceEntitlementInterceptor implements HandlerInterceptor { + + private final InstanceEntitlementGate gate; + + public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) { + this.gate = gate; + } + + @Override + public boolean preHandle( + HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + GateDecision decision; + try { + decision = gate.evaluate(BillableOperationClassifier.isBillable(request)); + } catch (RuntimeException e) { + // Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never + // turn into a hard block on billable work. + log.debug("Account-link gate evaluation failed; allowing request", e); + return true; + } + if (decision.allowed()) { + return true; + } + + log.debug("Account-link gate blocked {} ({})", request.getRequestURI(), decision.reason()); + response.setStatus(HttpStatus.PAYMENT_REQUIRED.value()); + response.setContentType("application/json"); + response.getWriter() + .write( + "{\"error\":\"ACCOUNT_LINK_REQUIRED\",\"reason\":\"" + + decision.reason().name() + + "\"}"); + return false; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 3c713206de..ee449b31f2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -33,6 +33,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.storage.repository", "stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.accountlink", "stirling.software.proprietary.policy.source" }) @EntityScan({ @@ -41,6 +42,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.storage.model", "stirling.software.proprietary.workflow.model", "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.accountlink", "stirling.software.proprietary.policy.source" }) public class DatabaseConfig { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java new file mode 100644 index 0000000000..7d954a4398 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -0,0 +1,192 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.ConnectException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import tools.jackson.databind.ObjectMapper; + +/** + * Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register + * relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on + * unreachable) behaviour hold. + */ +class AccountLinkClientTest { + + private AccountLinkProperties properties; + private HttpClient httpClient; + private AccountLinkClient client; + + @BeforeEach + void setUp() { + properties = new AccountLinkProperties(); + properties.setEnabled(true); + properties.setSaasBaseUrl("https://saas.example.com"); + httpClient = mock(HttpClient.class); + client = new AccountLinkClient(properties, new ObjectMapper(), httpClient); + } + + @SuppressWarnings("unchecked") + private HttpResponse response(int status, String body) { + HttpResponse resp = mock(HttpResponse.class); + when(resp.statusCode()).thenReturn(status); + when(resp.body()).thenReturn(body); + return resp; + } + + @Test + @SuppressWarnings("unchecked") + void registerRelaysJwtAndParsesCredential() throws Exception { + // Build the stub response first: nesting response() inside when() trips Mockito's + // unfinished-stubbing check (inner when() runs mid outer when()). + HttpResponse resp = + response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server"); + + assertEquals("dev-1", result.deviceId()); + assertEquals("sec-1", result.deviceSecret()); + assertEquals(42L, result.teamId()); + + HttpRequest sent = captor.getValue(); + assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null)); + assertEquals( + "https://saas.example.com/api/v1/account-link/register", sent.uri().toString()); + } + + @Test + @SuppressWarnings("unchecked") + void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception { + HttpResponse resp = response(401, "{\"error\":\"unauthorized\"}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + AccountLinkClient.UpstreamException ex = + assertThrows( + AccountLinkClient.UpstreamException.class, + () -> client.register("jwt", null)); + assertEquals(401, ex.status()); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementParsesSnapshotAndSendsDeviceHeaders() throws Exception { + HttpResponse resp = + response( + 200, + "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":10,\"periodCapUnits\":100,\"state\":\"OK\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1"); + + assertNotNull(e); + assertEquals(true, e.subscribed()); + assertEquals(10, e.periodSpendUnits()); + assertEquals(100L, e.periodCapUnits()); + assertEquals(EntitlementState.OK, e.state()); + + HttpRequest sent = captor.getValue(); + assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null)); + assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementMapsOverLimitState() throws Exception { + // Pins the consume side of the wire contract: InstanceController emits "OVER_LIMIT" (for a + // DEGRADED team) and the client must map it to the gate-blocking state. + HttpResponse resp = + response( + 200, + "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":1300,\"periodCapUnits\":1250,\"state\":\"OVER_LIMIT\"}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + + InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1"); + + assertNotNull(e); + assertEquals(EntitlementState.OVER_LIMIT, e.state()); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementReturnsNullWhenUnreachable() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenThrow(new ConnectException("refused")); + // Null = unknown → the cache/gate fail open. + assertNull(client.fetchEntitlement("dev-1", "sec-1")); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementReturnsNullOnServerError() throws Exception { + // 5xx is a transient/server failure, not a credential deny → null, the cache fails open. + HttpResponse resp = response(503, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + assertNull(client.fetchEntitlement("dev-1", "sec-1")); + } + + @Test + @SuppressWarnings("unchecked") + void fetchEntitlementThrowsRevokedOnDeny() throws Exception { + // 401/403 = authoritative deny (revoked/invalid credential) → RevokedException, NOT null: + // the cache must block billable work rather than fail open on a stale snapshot. + for (int status : new int[] {401, 403}) { + HttpResponse resp = response(status, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + AccountLinkClient.RevokedException ex = + assertThrows( + AccountLinkClient.RevokedException.class, + () -> client.fetchEntitlement("dev-1", "sec-1")); + assertEquals(status, ex.status()); + } + } + + @Test + @SuppressWarnings("unchecked") + void revokeSelfSendsDeviceHeadersAndReturnsTrueOn2xx() throws Exception { + HttpResponse resp = response(204, ""); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + assertEquals(true, client.revokeSelf("dev-1", "sec-1")); + + HttpRequest sent = captor.getValue(); + assertEquals("https://saas.example.com/api/v1/instance/revoke-self", sent.uri().toString()); + assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null)); + assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null)); + assertEquals("POST", sent.method()); + } + + @Test + @SuppressWarnings("unchecked") + void revokeSelfReturnsFalseOnErrorStatus() throws Exception { + HttpResponse resp = response(403, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + assertEquals(false, client.revokeSelf("dev-1", "sec-1")); + } + + @Test + @SuppressWarnings("unchecked") + void revokeSelfReturnsFalseWhenUnreachable() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenThrow(new ConnectException("refused")); + assertEquals(false, client.revokeSelf("dev-1", "sec-1")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java new file mode 100644 index 0000000000..6e4a816bb0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -0,0 +1,68 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest; + +/** + * The local (self-hosted) account-link controller's error mapping: an upstream auth rejection + * surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport + * faults are a 502. + */ +class AccountLinkControllerTest { + + private AccountLinkService service; + private AccountLinkController controller; + + @BeforeEach + void setUp() { + service = mock(AccountLinkService.class); + controller = new AccountLinkController(service); + } + + @Test + void link_missingJwt_returns400() { + ResponseEntity resp = controller.link(new LinkRequest(" ", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void link_upstreamUnauthorized_maps401() throws Exception { + when(service.link("jwt", null)) + .thenThrow(new AccountLinkClient.UpstreamException(401, "bad token")); + ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void link_upstreamForbidden_maps403() throws Exception { + when(service.link("jwt", null)) + .thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden")); + ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void link_upstreamServerError_maps502() throws Exception { + when(service.link("jwt", null)) + .thenThrow(new AccountLinkClient.UpstreamException(500, "boom")); + ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + } + + @Test + void link_transportFailure_maps502() throws Exception { + when(service.link("jwt", null)).thenThrow(new IOException("connection refused")); + ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java new file mode 100644 index 0000000000..b909fb37a0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java @@ -0,0 +1,111 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AccountLinkServiceTest { + + private AccountLinkClient client; + private DeviceCredentialStore store; + private EntitlementCache cache; + private AccountLinkService service; + + @BeforeEach + void setUp() { + client = mock(AccountLinkClient.class); + store = mock(DeviceCredentialStore.class); + cache = mock(EntitlementCache.class); + service = new AccountLinkService(client, store, cache); + } + + @Test + void link_storesCredentialAndInvalidatesCache() throws IOException { + when(client.register("jwt", "name")) + .thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L)); + DeviceCredential stored = new DeviceCredential(); + stored.setDeviceId("dev-1"); + stored.setTeamId(7L); + stored.setLinkedAt(LocalDateTime.now()); + when(store.get()).thenReturn(Optional.of(stored)); + + AccountLinkService.LinkStatus status = service.link("jwt", "name"); + + verify(store).save("dev-1", "sec-1", 7L); + verify(cache).invalidate(); + assertTrue(status.linked()); + assertEquals("dev-1", status.deviceId()); + assertEquals(7L, status.teamId()); + } + + @Test + void link_propagatesRegisterFailure() throws IOException { + when(client.register(any(), any())).thenThrow(new IOException("boom")); + org.junit.jupiter.api.Assertions.assertThrows( + IOException.class, () -> service.link("jwt", null)); + verify(cache, org.mockito.Mockito.never()).invalidate(); + } + + @Test + void status_unlinkedWhenNoCredential() { + when(store.get()).thenReturn(Optional.empty()); + AccountLinkService.LinkStatus status = service.status(); + assertFalse(status.linked()); + } + + @Test + void unlink_callsSaasRevokeBeforeClearingLocally() { + DeviceCredential cred = new DeviceCredential(); + cred.setDeviceId("dev-1"); + cred.setDeviceSecret("sec-1"); + cred.setTeamId(7L); + cred.setLinkedAt(LocalDateTime.now()); + when(store.get()).thenReturn(Optional.of(cred)); + when(client.revokeSelf("dev-1", "sec-1")).thenReturn(true); + + service.unlink(); + + verify(client).revokeSelf("dev-1", "sec-1"); + verify(store).clear(); + verify(cache).invalidate(); + } + + @Test + void unlink_clearsLocallyEvenWhenSaasRevokeFails() { + DeviceCredential cred = new DeviceCredential(); + cred.setDeviceId("dev-1"); + cred.setDeviceSecret("sec-1"); + cred.setLinkedAt(LocalDateTime.now()); + when(store.get()).thenReturn(Optional.of(cred)); + // SaaS unreachable / returns non-2xx. + when(client.revokeSelf("dev-1", "sec-1")).thenReturn(false); + + service.unlink(); + + // Local clear MUST still happen — admin's intent wins; orphan row is a follow-up. + verify(store).clear(); + verify(cache).invalidate(); + } + + @Test + void unlink_whenAlreadyUnlinked_skipsSaasRevoke() { + when(store.get()).thenReturn(Optional.empty()); + + service.unlink(); + + org.mockito.Mockito.verifyNoInteractions(client); + verify(store).clear(); + verify(cache).invalidate(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java new file mode 100644 index 0000000000..275026794a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java @@ -0,0 +1,49 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.common.service.InternalApiClient; + +class BillableOperationClassifierTest { + + @Test + void aiPathIsBillable() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo"); + assertTrue(BillableOperationClassifier.isBillable(req)); + } + + @Test + void automationHeaderIsBillable() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1"); + assertTrue(BillableOperationClassifier.isBillable(req)); + } + + @Test + void plainManualToolIsFree() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + assertFalse(BillableOperationClassifier.isBillable(req)); + } + + @Test + void aiSegmentNotAtPathStartIsFree() { + // Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a + // proxy prefix) must NOT classify a manual tool as billable. + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo"); + assertFalse(BillableOperationClassifier.isBillable(req)); + } + + @Test + void aiPathUnderContextPathIsBillable() { + // A real context-path deployment still classifies: //api/v1/ai/** is billable. + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo"); + req.setContextPath("/stirling"); + assertTrue(BillableOperationClassifier.isBillable(req)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/EntitlementCacheTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/EntitlementCacheTest.java new file mode 100644 index 0000000000..3dbeb1ef5c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/EntitlementCacheTest.java @@ -0,0 +1,114 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class EntitlementCacheTest { + + private DeviceCredentialStore store; + private AccountLinkClient client; + private AccountLinkProperties properties; + private EntitlementCache cache; + + @BeforeEach + void setUp() { + store = mock(DeviceCredentialStore.class); + client = mock(AccountLinkClient.class); + properties = new AccountLinkProperties(); + properties.setEntitlementCacheSeconds(300); + cache = new EntitlementCache(store, client, properties); + } + + private DeviceCredential cred() { + DeviceCredential c = new DeviceCredential(); + c.setDeviceId("dev-1"); + c.setDeviceSecret("sec-1"); + c.setTeamId(1L); + c.setLinkedAt(LocalDateTime.now()); + return c; + } + + @Test + void unlinked_returnsEmpty() { + when(store.get()).thenReturn(Optional.empty()); + assertTrue(cache.current().isEmpty()); + } + + @Test + void linked_fetchesAndCachesWithinTtl() { + InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK); + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap); + + assertEquals(snap, cache.current().orElseThrow()); + // Second read within TTL must not re-fetch. + assertEquals(snap, cache.current().orElseThrow()); + verify(client, times(1)).fetchEntitlement(any(), any()); + } + + @Test + void linked_unreachable_keepsLastKnownSnapshot_failOpenFriendly() { + InstanceEntitlement snap = new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK); + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap); + assertEquals(snap, cache.current().orElseThrow()); + + // TTL elapsed → refresh attempted, but the SaaS side is now unreachable (null). + cache.invalidate(); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null); + assertEquals(snap, cache.current().orElseThrow(), "stale snapshot retained on failure"); + } + + @Test + void linked_neverFetched_unreachable_backsOffWithinTtl() { + // No prior snapshot + SaaS unreachable: the gate fails open (empty), but a failed + // attempt stamps the TTL so a second read within the window does NOT re-fetch — + // no sustained hammer of blocking round-trips against a dead endpoint. + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null); + + assertTrue(cache.current().isEmpty()); + assertTrue(cache.current().isEmpty()); + verify(client, times(1)).fetchEntitlement(any(), any()); + } + + @Test + void linked_revoked_blocksAndDropsStaleEntitlement() { + InstanceEntitlement entitled = + new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK); + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(entitled); + assertEquals(entitled, cache.current().orElseThrow()); + + // Credential revoked: the next refresh is an authoritative deny. The cache must NOT keep + // serving the stale entitled snapshot — it replaces it with a blocked REVOKED one. + cache.invalidate(); + when(client.fetchEntitlement(anyString(), anyString())) + .thenThrow(new AccountLinkClient.RevokedException(401)); + assertEquals(EntitlementState.REVOKED, cache.current().orElseThrow().state()); + } + + @Test + void invalidate_forcesRefetch() { + InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK); + when(store.get()).thenReturn(Optional.of(cred())); + when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap); + + cache.current(); + cache.invalidate(); + cache.current(); + verify(client, times(2)).fetchEntitlement(any(), any()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java new file mode 100644 index 0000000000..0c250fd3e9 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java @@ -0,0 +1,117 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.accountlink.GateDecision.Reason; + +/** + * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and + * over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is + * needed. + */ +class InstanceEntitlementGateTest { + + private static InstanceEntitlement free() { + return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK); + } + + private static InstanceEntitlement exhaustedUnsubscribed() { + return new InstanceEntitlement(false, 0, 0, null, EntitlementState.OVER_LIMIT); + } + + private static InstanceEntitlement subscribedWithinCap() { + return new InstanceEntitlement(true, 0, 10, 100L, EntitlementState.OK); + } + + private static InstanceEntitlement subscribedOverCap() { + return new InstanceEntitlement(true, 0, 100, 100L, EntitlementState.OK); + } + + @Test + void flagOff_allowsEverything_evenBillableUnlinked() { + GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty()); + assertTrue(d.allowed()); + assertEquals(Reason.FLAG_OFF, d.reason()); + } + + @Test + void manualTool_alwaysFree_evenUnlinked() { + GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty()); + assertTrue(d.allowed()); + assertEquals(Reason.MANUAL_FREE, d.reason()); + } + + @Test + void billable_notLinked_blocksWithLinkSignal() { + GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty()); + assertFalse(d.allowed()); + assertEquals(Reason.NOT_LINKED, d.reason()); + } + + @Test + void billable_linked_entitlementUnreachable_failsOpen() { + GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty()); + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void billable_linked_freePoolAvailable_allows() { + GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free())); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(exhaustedUnsubscribed())); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_subscribedWithinCap_allows() { + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap())); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedOverCap_blocks() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap())); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_revoked_blocksWithRevokedSignal() { + // Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED — + // blocks distinctly from over-limit, even though the snapshot is "present". + InstanceEntitlement revoked = + new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED); + GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked)); + assertFalse(d.allowed()); + assertEquals(Reason.REVOKED, d.reason()); + } + + @Test + void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() { + // Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive. + InstanceEntitlement conflicting = + new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT); + GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting)); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java new file mode 100644 index 0000000000..f764f5e836 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java @@ -0,0 +1,71 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Verifies {@link InstanceEntitlementGate#evaluate} resolves live state from store + cache. */ +class InstanceEntitlementGateWiringTest { + + private AccountLinkProperties properties; + private DeviceCredentialStore store; + private EntitlementCache cache; + private InstanceEntitlementGate gate; + + @BeforeEach + void setUp() { + properties = new AccountLinkProperties(); + properties.setEnabled(true); + store = mock(DeviceCredentialStore.class); + cache = mock(EntitlementCache.class); + gate = new InstanceEntitlementGate(properties, store, cache); + } + + @Test + void manualNeverConsultsStoreOrCache() { + GateDecision d = gate.evaluate(false); + assertTrue(d.allowed()); + assertEquals(GateDecision.Reason.MANUAL_FREE, d.reason()); + verify(store, never()).isLinked(); + verify(cache, never()).current(); + } + + @Test + void billableUnlinkedDoesNotHitCache() { + when(store.isLinked()).thenReturn(false); + GateDecision d = gate.evaluate(true); + assertFalse(d.allowed()); + assertEquals(GateDecision.Reason.NOT_LINKED, d.reason()); + verify(cache, never()).current(); + } + + @Test + void billableLinkedConsultsCache() { + when(store.isLinked()).thenReturn(true); + when(cache.current()) + .thenReturn( + Optional.of( + new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK))); + GateDecision d = gate.evaluate(true); + assertTrue(d.allowed()); + assertEquals(GateDecision.Reason.ENTITLED, d.reason()); + } + + @Test + void flagOffShortCircuits() { + properties.setEnabled(false); + GateDecision d = gate.evaluate(true); + assertTrue(d.allowed()); + assertEquals(GateDecision.Reason.FLAG_OFF, d.reason()); + verify(store, never()).isLinked(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java new file mode 100644 index 0000000000..709e3c0866 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class InstanceEntitlementInterceptorTest { + + @Mock private InstanceEntitlementGate gate; + + private boolean preHandle(MockHttpServletResponse response) throws Exception { + return new InstanceEntitlementInterceptor(gate) + .preHandle( + new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object()); + } + + @Test + void allowsWhenGateAllows() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertTrue(preHandle(response)); + assertEquals(200, response.getStatus()); + } + + @Test + void blocksWith402AndLinkSignalWhenGateBlocks() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertFalse(preHandle(response)); + assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), response.getStatus()); + assertEquals("application/json", response.getContentType()); + assertTrue(response.getContentAsString().contains("ACCOUNT_LINK_REQUIRED")); + assertTrue(response.getContentAsString().contains("NOT_LINKED")); + } + + @Test + void failsOpenWhenGateThrows() throws Exception { + // A DB / SaaS blip while resolving entitlement must never hard-block billable work. + when(gate.evaluate(anyBoolean())) + .thenThrow(new RuntimeException("entitlement source down")); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertTrue(preHandle(response)); + assertEquals(200, response.getStatus()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java new file mode 100644 index 0000000000..91974a6d04 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java @@ -0,0 +1,161 @@ +package stirling.software.saas.accountlink; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Account-link registration surface (combined-billing "Mode A"). + * + *

A self-hosted instance's local backend calls {@code POST /register} with the admin's + * short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new + * auth here). We resolve the caller's team, mint a device credential bound to it, and return the + * secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this + * JWT. + * + *

Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → + * 404. Leader-only, and the team is always derived from the caller (never the request body). + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/account-link") +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkController { + + private final AccountLinkService service; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public AccountLinkController( + AccountLinkService service, + TeamMembershipRepository memberRepo, + UserRepository userRepository) { + this.service = service; + this.memberRepo = memberRepo; + this.userRepository = userRepository; + } + + /** Optional display name for the instance (hostname / label). */ + public record RegisterRequest(String name) {} + + /** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */ + public record RegisterResponse( + Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {} + + public record InstanceRow( + Long instanceId, + String deviceId, + String name, + String createdAt, + String lastSeenAt, + boolean revoked) {} + + @PostMapping("/register") + @PreAuthorize("isAuthenticated()") + public ResponseEntity register( + @RequestBody(required = false) RegisterRequest req, Authentication auth) { + LeaderTeam lt = resolveLeaderTeam(auth); + if (lt.error() != null) { + return ResponseEntity.status(lt.error()).build(); + } + String name = req != null ? req.name() : null; + AccountLinkService.RegisteredInstance reg = + service.register(lt.teamId(), lt.userId(), name); + return ResponseEntity.status(HttpStatus.CREATED) + .body( + new RegisterResponse( + reg.instanceId(), + lt.teamId(), + reg.deviceId(), + reg.deviceSecret(), + reg.name())); + } + + @GetMapping("/instances") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> list(Authentication auth) { + LeaderTeam lt = resolveLeaderTeam(auth); + if (lt.error() != null) { + return ResponseEntity.status(lt.error()).build(); + } + List rows = + service.list(lt.teamId()).stream() + .map( + i -> + new InstanceRow( + i.getInstanceId(), + i.getDeviceId(), + i.getName(), + i.getCreatedAt() != null + ? i.getCreatedAt().toString() + : null, + i.getLastSeenAt() != null + ? i.getLastSeenAt().toString() + : null, + i.getRevokedAt() != null)) + .toList(); + return ResponseEntity.ok(rows); + } + + @PostMapping("/instances/{instanceId}/revoke") + @PreAuthorize("isAuthenticated()") + public ResponseEntity revoke(@PathVariable Long instanceId, Authentication auth) { + LeaderTeam lt = resolveLeaderTeam(auth); + if (lt.error() != null) { + return ResponseEntity.status(lt.error()).build(); + } + boolean ok = service.revoke(lt.teamId(), instanceId); + return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); + } + + // --------------------------------------------------------------------------------------- + // Helpers — team always derived from the caller; instance linking is a leader (billing) action. + // --------------------------------------------------------------------------------------- + + /** + * Resolved caller team, or an {@code error} status to return (teamId/userId null when error). + */ + private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {} + + private LeaderTeam resolveLeaderTeam(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED); + } + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + TeamMembership m = rows.get(0); + if (m.getRole() != TeamRole.LEADER) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + return new LeaderTeam(m.getTeam().getId(), user.getId(), null); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java new file mode 100644 index 0000000000..c31fc1b03e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java @@ -0,0 +1,117 @@ +package stirling.software.saas.accountlink; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +/** + * Account-link instance registration + lifecycle (combined-billing "Mode A"). + * + *

Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound + * to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its + * unattended entitlement reads with that credential. + * + *

Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so + * {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkService { + + /** 32 bytes of entropy → URL-safe secret; high enough that an unsalted SHA-256 hash is fine. */ + private static final int SECRET_BYTES = 32; + + private final LinkedInstanceRepository repo; + private final SecureRandom random = new SecureRandom(); + + public AccountLinkService(LinkedInstanceRepository repo) { + this.repo = repo; + } + + /** Result of {@link #register}; {@code deviceSecret} is plaintext and returned exactly once. */ + public record RegisteredInstance( + Long instanceId, String deviceId, String deviceSecret, String name) {} + + /** + * Creates a new linked instance for {@code teamId}, returning the one-time plaintext secret. + */ + @Transactional + public RegisteredInstance register(Long teamId, Long createdByUserId, String name) { + String deviceId = UUID.randomUUID().toString(); + String deviceSecret = randomSecret(); + + LinkedInstance instance = new LinkedInstance(); + instance.setTeamId(teamId); + instance.setCreatedByUserId(createdByUserId); + instance.setDeviceId(deviceId); + instance.setDeviceSecretHash(sha256Hex(deviceSecret)); + instance.setName(name); + repo.save(instance); + + log.info( + "Account-link: registered instance {} (device {}) for team {}", + instance.getInstanceId(), + deviceId, + teamId); + return new RegisteredInstance(instance.getInstanceId(), deviceId, deviceSecret, name); + } + + /** + * All instances for a team, newest first (includes revoked, for the "Linked instances" list). + */ + @Transactional(readOnly = true) + public List list(Long teamId) { + return repo.findByTeamIdOrderByCreatedAtDesc(teamId); + } + + /** + * Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by + * a different team (so a caller can never revoke another team's instance). Idempotent. + */ + @Transactional + public boolean revoke(Long teamId, Long instanceId) { + Optional found = repo.findById(instanceId); + if (found.isEmpty() || !found.get().getTeamId().equals(teamId)) { + return false; + } + LinkedInstance instance = found.get(); + if (instance.getRevokedAt() == null) { + instance.setRevokedAt(LocalDateTime.now()); + repo.save(instance); + log.info("Account-link: revoked instance {} for team {}", instanceId, teamId); + } + return true; + } + + private String randomSecret() { + byte[] buf = new byte[SECRET_BYTES]; + random.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + /** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */ + static String sha256Hex(String value) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java new file mode 100644 index 0000000000..a2fd13a095 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java @@ -0,0 +1,108 @@ +package stirling.software.saas.accountlink; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +/** + * Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A"). + * + *

Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link + * LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the + * stored hash. On a match it sets a {@link LinkedInstanceAuthenticationToken} (team-scoped, {@code + * ROLE_LINKED_INSTANCE}); otherwise it does nothing and lets the chain continue (→ 401 on a + * protected endpoint). + * + *

Read-only and path-scoped to {@code /api/v1/instance/**}: the device principal is never + * established for user-facing endpoints, so a leaked secret can only reach the instance surface. + * Gated behind {@code stirling.billing.account-link.enabled}; absent when the flag is off, so + * {@code SupabaseSecurityConfig} never wires it in. + */ +@Slf4j +@Component +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class DeviceCredentialAuthenticationFilter extends OncePerRequestFilter { + + static final String HEADER_DEVICE_ID = "X-Device-Id"; + static final String HEADER_DEVICE_SECRET = "X-Device-Secret"; + static final String INSTANCE_PATH_PREFIX = "/api/v1/instance/"; + + private final LinkedInstanceRepository repo; + + public DeviceCredentialAuthenticationFilter(LinkedInstanceRepository repo) { + this.repo = repo; + } + + /** Only the instance surface uses the device credential; everything else skips this filter. */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith(INSTANCE_PATH_PREFIX); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String deviceId = request.getHeader(HEADER_DEVICE_ID); + String secret = request.getHeader(HEADER_DEVICE_SECRET); + + if (deviceId != null + && secret != null + && SecurityContextHolder.getContext().getAuthentication() == null) { + repo.findByDeviceIdAndRevokedAtIsNull(deviceId) + .ifPresent( + instance -> { + if (constantTimeEquals( + AccountLinkService.sha256Hex(secret), + instance.getDeviceSecretHash())) { + SecurityContextHolder.getContext() + .setAuthentication( + new LinkedInstanceAuthenticationToken( + instance.getInstanceId(), + instance.getTeamId())); + // Stamp liveness, best-effort. Auth is already set above; a + // transient write failure must NOT 500 an otherwise-valid + // request, so swallow it. Targeted single-column UPDATE (not a + // full save) so a concurrent revoke between the read above and + // this write can't be clobbered back to active. + try { + repo.touchLastSeen( + instance.getInstanceId(), LocalDateTime.now()); + } catch (RuntimeException e) { + log.debug( + "last_seen_at update failed for device {}: {}", + deviceId, + e.getMessage()); + } + } else { + log.debug("Device credential mismatch for device {}", deviceId); + } + }); + } + + chain.doFilter(request, response); + } + + private static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + return MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java new file mode 100644 index 0000000000..7392eb928a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java @@ -0,0 +1,131 @@ +package stirling.software.saas.accountlink; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.entitlement.EntitlementService; +import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.model.EntitlementState; + +/** + * Instance-facing surface (combined-billing "Mode A"), authenticated by the device + * credential — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device + * credential is scoped here and nowhere else. + * + *

{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid + * device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET + * /entitlement} is the read the local gate consumes — the same team-scoped snapshot the FE wallet + * sees, trimmed to the fields the gate needs (subscription, free pool, period spend/cap, state), + * and built on the same device-credential auth. + * + *

Gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → 404. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/instance") +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class InstanceController { + + private final EntitlementService entitlementService; + private final TeamBillingService billingService; + private final AccountLinkService accountLinkService; + + public InstanceController( + EntitlementService entitlementService, + TeamBillingService billingService, + AccountLinkService accountLinkService) { + this.entitlementService = entitlementService; + this.billingService = billingService; + this.accountLinkService = accountLinkService; + } + + public record WhoAmIResponse(Long instanceId, Long teamId) {} + + /** + * Minimal entitlement view the local gate enforces against. {@code periodCapUnits} null = + * uncapped. {@code state} is the coarse OK / OVER_LIMIT vocabulary the instance gate parses + * (see {@link #coarseState}), not the SaaS feature-state enum. + */ + public record EntitlementResponse( + boolean subscribed, + long freeRemainingUnits, + long periodSpendUnits, + Long periodCapUnits, + String state) {} + + @GetMapping("/whoami") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + public ResponseEntity whoami(Authentication auth) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + // Belt-and-braces: hasRole already guarantees this, but never leak a non-instance + // principal. + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + return ResponseEntity.ok(new WhoAmIResponse(token.getInstanceId(), token.getTeamId())); + } + + /** + * Revokes this instance's own credential — a credential can mark itself revoked the same way a + * session logs itself out. Called by the proprietary backend on local unlink so the SaaS row + * gets {@code revoked_at} set; idempotent (already-revoked → still 204). + */ + @PostMapping("/revoke-self") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + public ResponseEntity revokeSelf(Authentication auth) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + accountLinkService.revoke(token.getTeamId(), token.getInstanceId()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/entitlement") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + @Transactional(readOnly = true) + public ResponseEntity entitlement(Authentication auth) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + Long teamId = token.getTeamId(); + + // Same composition the FE wallet uses: billing facts (subscription, free pool) from + // TeamBillingService, period spend/cap + state from the entitlement snapshot. + TeamBillingContext billing = billingService.forTeam(teamId); + EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); + + return ResponseEntity.ok( + new EntitlementResponse( + billing.subscribed(), + billing.freeRemainingUnits(), + snap.periodSpendUnits(), + snap.periodCapUnits(), + coarseState(snap.state()))); + } + + /** + * Collapses the SaaS feature-state machine into the OK / OVER_LIMIT vocabulary the instance + * gate parses. DEGRADED means automation + AI are gated off — which, for a gate that governs + * only billable work (manual tools are free-pathed before it), is exactly OVER_LIMIT; FULL and + * WARNED are OK. + */ + private static String coarseState(EntitlementState state) { + return state == EntitlementState.DEGRADED ? "OVER_LIMIT" : "OK"; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java new file mode 100644 index 0000000000..ec92c97758 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java @@ -0,0 +1,77 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code + * linked_instance}, V22). + * + *

Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's + * short-lived Supabase JWT. Registration mints a {@code device_id} (public) plus a {@code + * device_secret} (high-entropy, returned once and stored only on the instance — we keep an unsalted + * SHA-256 hash, the same posture as API keys). The instance authenticates its unattended + * entitlement reads with that device credential, so no long-lived user JWT lives on the server + * side. + * + *

{@code revoked_at IS NULL} means active; revoking sets it and the credential stops + * authenticating. The whole surface is gated behind {@code stirling.billing.account-link.enabled}. + */ +@Entity +@Table(name = "linked_instance") +@Getter +@Setter +@NoArgsConstructor +public class LinkedInstance { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "instance_id") + private Long instanceId; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** + * Admin who registered the instance; informational (no FK, so a user delete never offlines it). + */ + @Column(name = "created_by_user_id") + private Long createdByUserId; + + /** Public, non-secret identifier the instance presents on every request. */ + @Column(name = "device_id", nullable = false, unique = true, length = 64) + private String deviceId; + + /** SHA-256 hex of the device secret; the secret itself is never stored. */ + @Column(name = "device_secret_hash", nullable = false, length = 64) + private String deviceSecretHash; + + /** Operator-set display label (hostname etc.) for the "Linked instances" list. */ + @Column(name = "name", length = 255) + private String name; + + /** Insert time; Hibernate populates this on persist (DB DEFAULT is belt-and-braces). */ + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** Stamped when the device credential last authenticated; powers staleness display. */ + @Column(name = "last_seen_at") + private LocalDateTime lastSeenAt; + + /** NULL = active. Set on unlink/revoke; a revoked credential fails authentication. */ + @Column(name = "revoked_at") + private LocalDateTime revokedAt; +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java new file mode 100644 index 0000000000..af883bd66a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java @@ -0,0 +1,45 @@ +package stirling.software.saas.accountlink; + +import java.util.List; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * Authentication for a linked self-hosted instance (combined-billing "Mode A"). + * + *

Deliberately not a user: the principal is the instance ({@code instanceId}) bound to + * a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code + * User} and creates no user row — a device credential can never act as a person, only as its team's + * instance, and only on the instance-facing endpoints. + */ +public class LinkedInstanceAuthenticationToken extends AbstractAuthenticationToken { + + private final Long instanceId; + private final Long teamId; + + public LinkedInstanceAuthenticationToken(Long instanceId, Long teamId) { + super(List.of(new SimpleGrantedAuthority("ROLE_LINKED_INSTANCE"))); + this.instanceId = instanceId; + this.teamId = teamId; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return null; // the secret is never retained on the authentication + } + + @Override + public Object getPrincipal() { + return instanceId; + } + + public Long getInstanceId() { + return instanceId; + } + + public Long getTeamId() { + return teamId; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java new file mode 100644 index 0000000000..dae6a2a69e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java @@ -0,0 +1,43 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +/** + * Data access for {@link LinkedInstance}. Plain Spring Data JPA against {@code stirling_pdf} — + * native schema access, no RPC, consistent with the rest of the SaaS backend. + */ +public interface LinkedInstanceRepository extends JpaRepository { + + /** + * Active-credential lookup for the device-credential auth filter (revoked rows never match). + */ + Optional findByDeviceIdAndRevokedAtIsNull(String deviceId); + + /** Backs the portal "Linked instances" list (includes revoked, newest first). */ + List findByTeamIdOrderByCreatedAtDesc(Long teamId); + + /** Active (non-revoked) linked instances on a team — the orphan guard's count. */ + long countByTeamIdAndRevokedAtIsNull(Long teamId); + + /** + * Stamps liveness on a single instance. A targeted single-column UPDATE rather than a + * full-entity {@code save}: the auth filter loads the instance outside a transaction, so a full + * save would write back the stale (in-memory {@code null}) {@code revoked_at} and could + * silently un-revoke a credential that was revoked between the read and the write. The {@code + * revoked_at IS NULL} guard makes this a no-op once revoked. + */ + @Modifying + @Transactional + @Query( + "UPDATE LinkedInstance li SET li.lastSeenAt = :now " + + "WHERE li.instanceId = :instanceId AND li.revokedAt IS NULL") + int touchLastSeen(@Param("instanceId") Long instanceId, @Param("now") LocalDateTime now); +} diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java index 3c6b0d14df..5e90df6bf5 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java @@ -14,12 +14,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @Profile("saas") @EnableJpaRepositories( basePackages = { + "stirling.software.saas.accountlink", "stirling.software.saas.repository", "stirling.software.saas.billing.repository", "stirling.software.saas.ai.repository", "stirling.software.saas.payg.repository" }) @EntityScan({ + "stirling.software.saas.accountlink", "stirling.software.saas.model", "stirling.software.saas.billing.model", "stirling.software.saas.ai.model", diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java new file mode 100644 index 0000000000..6b8bb2c883 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java @@ -0,0 +1,150 @@ +package stirling.software.saas.payg.api; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.stripe.StripeInvoiceDao; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Read-only Stripe-invoices surface for the linked org's billing page. + * + *

{@code GET /api/v1/payg/invoices?limit=N} returns the team's most recent Stripe invoices, + * sourced from the {@code stripe.invoices} table the Sync Engine maintains. The caller's team is + * resolved from the authenticated principal (same pattern as {@link PaygWalletController}); we + * never trust a team id from the request. + * + *

Defensive: when the team has no {@code stripe_customer_id} (not subscribed, or pre-checkout) + * or the {@code stripe} schema isn't synced (H2 tests, sync engine off), we return {@code 200} with + * an empty list rather than 500 — the UI renders "no invoices yet". This keeps the page working + * through every link/subscription state. + * + *

{@code hostedInvoiceUrl} + {@code invoicePdf} are Stripe-hosted links the portal can deep-link + * from. We don't proxy the PDF ourselves; Stripe handles auth + caching. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/payg") +@Profile("saas") +public class PaygInvoicesController { + + private static final int DEFAULT_LIMIT = 20; + private static final int MAX_LIMIT = 100; + + private final StripeInvoiceDao invoiceDao; + private final PaygTeamExtensionsRepository extRepo; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public PaygInvoicesController( + StripeInvoiceDao invoiceDao, + PaygTeamExtensionsRepository extRepo, + TeamMembershipRepository memberRepo, + UserRepository userRepository) { + this.invoiceDao = Objects.requireNonNull(invoiceDao, "invoiceDao"); + this.extRepo = Objects.requireNonNull(extRepo, "extRepo"); + this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo"); + this.userRepository = Objects.requireNonNull(userRepository, "userRepository"); + } + + /** The shape the portal renders. Trimmed; never echoes raw Stripe object fields verbatim. */ + public record InvoiceResponse( + String id, + String number, + String status, + Long totalMinor, + String currency, + String createdAt, + String periodStart, + String periodEnd, + String hostedInvoiceUrl, + String invoicePdf, + String description, + Long pdfsProcessed) {} + + @GetMapping("/invoices") + @PreAuthorize("isAuthenticated()") + @Transactional(readOnly = true) + public ResponseEntity> list( + @RequestParam(name = "limit", required = false) Integer limit, Authentication auth) { + + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + // Resolve the caller's team from their primary membership — same pattern as + // PaygWalletController. The team id NEVER comes from the request. + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return ResponseEntity.ok(List.of()); + } + Long teamId = rows.get(0).getTeam().getId(); + + // No PAYG extension row OR no Stripe customer id → team has never subscribed → no + // invoices. Empty list, not 404 — the UI distinguishes "no invoices yet" from a + // genuine error and we don't want to error a happy free team. + Optional ext = extRepo.findById(teamId); + if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) { + return ResponseEntity.ok(List.of()); + } + + int safeLimit = clampLimit(limit); + List body = + invoiceDao.findRecentByCustomer(ext.get().getStripeCustomerId(), safeLimit).stream() + .map(PaygInvoicesController::toResponse) + .toList(); + return ResponseEntity.ok(body); + } + + private static int clampLimit(Integer requested) { + if (requested == null) return DEFAULT_LIMIT; + return Math.max(1, Math.min(requested, MAX_LIMIT)); + } + + private static InvoiceResponse toResponse(StripeInvoiceDao.InvoiceRow r) { + return new InvoiceResponse( + r.id(), + r.number(), + r.status(), + r.totalMinor(), + r.currency(), + iso(r.createdAt()), + iso(r.periodStart()), + iso(r.periodEnd()), + r.hostedInvoiceUrl(), + r.invoicePdf(), + r.description(), + r.pdfsProcessed()); + } + + private static String iso(LocalDateTime ldt) { + return ldt == null ? null : ldt.toString(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java new file mode 100644 index 0000000000..724d6715d3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java @@ -0,0 +1,108 @@ +package stirling.software.saas.payg.api; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.stripe.StripePaymentMethodDao; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Read-only default-payment-method surface for the subscribed billing page. + * + *

{@code GET /api/v1/payg/payment-method} returns the team's default card (brand / last4 / + * expiry), sourced from {@code stripe.payment_methods} (Sync Engine mirror). The caller's team is + * resolved from the authenticated principal — never trusted from the request — exactly as {@link + * PaygInvoicesController} does. + * + *

Defensive: no team, no {@code stripe_customer_id} (free / pre-checkout), or the card simply + * not in the mirror all degrade to {@code 200 present=false} rather than an error. Card edits never + * happen here; the portal deep-links to Stripe's hosted customer portal for that. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/payg") +@Profile("saas") +public class PaygPaymentMethodController { + + /** Trimmed default-card shape. {@code present=false} carries no card fields. */ + public record PaymentMethodResponse( + boolean present, String brand, String last4, Integer expMonth, Integer expYear) { + static PaymentMethodResponse absent() { + return new PaymentMethodResponse(false, null, null, null, null); + } + } + + private final StripePaymentMethodDao paymentMethodDao; + private final PaygTeamExtensionsRepository extRepo; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public PaygPaymentMethodController( + StripePaymentMethodDao paymentMethodDao, + PaygTeamExtensionsRepository extRepo, + TeamMembershipRepository memberRepo, + UserRepository userRepository) { + this.paymentMethodDao = Objects.requireNonNull(paymentMethodDao, "paymentMethodDao"); + this.extRepo = Objects.requireNonNull(extRepo, "extRepo"); + this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo"); + this.userRepository = Objects.requireNonNull(userRepository, "userRepository"); + } + + @GetMapping("/payment-method") + @PreAuthorize("isAuthenticated()") + @Transactional(readOnly = true) + public ResponseEntity get(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return ResponseEntity.ok(PaymentMethodResponse.absent()); + } + Long teamId = rows.get(0).getTeam().getId(); + + Optional ext = extRepo.findById(teamId); + if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) { + return ResponseEntity.ok(PaymentMethodResponse.absent()); + } + + return ResponseEntity.ok( + paymentMethodDao + .findDefaultCard(ext.get().getStripeCustomerId()) + .map( + c -> + new PaymentMethodResponse( + true, + c.brand(), + c.last4(), + c.expMonth(), + c.expYear())) + .orElseGet(PaymentMethodResponse::absent)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java index d47b6e7365..29148a5ddb 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java @@ -15,7 +15,9 @@ import stirling.software.saas.payg.model.FeatureSet; *

State transitions: * *

    - *
  • {@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally. + *
  • {@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} (uncapped). + *
  • {@code capUnits <= 0} (an explicit $0 cap) → {@code DEGRADED}: metered work blocked, only + * the free grant + manual tools run. *
  • {@code spend / cap < warnPct} → {@code FULL}. *
  • MINIMAL semantics: under DEGRADED+MINIMAL manual server-side tools (gated by {@link * FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link @@ -49,9 +51,19 @@ public final class CapEvaluator { int degradeAtPct, FeatureSet degradedFeatureSet) { - if (capUnits == null || capUnits <= 0) { + if (capUnits == null) { + // No cap configured → uncapped, full feature set. return full(); } + if (capUnits <= 0) { + // An explicit cap that buys zero paid documents (a $0 cap, or one set + // below the per-document rate): metered work is blocked outright — + // only the free grant and manual tools run. DEGRADED, same as hitting + // a positive cap. + FeatureSet effective = + degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL; + return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective)); + } if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) { // Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise // degradation. The admin endpoints that set the policy should validate; this diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java new file mode 100644 index 0000000000..8e6b59f9ce --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java @@ -0,0 +1,215 @@ +package stirling.software.saas.payg.stripe; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import lombok.extern.slf4j.Slf4j; + +/** + * Read-only accessor for {@code stripe.invoices} (synced into Postgres by the Stripe Sync Engine). + * + *

    Same defensive posture as {@link StripeSubscriptionDao}: when the {@code stripe} schema is + * absent (H2 unit tests, sync engine not yet provisioned, or invoices not in the Sync Engine's + * target list), the lookup degrades to an empty list with a WARN — the caller renders "no invoices + * yet" rather than 500ing the page. + */ +@Slf4j +@Repository +@Profile("saas") +public class StripeInvoiceDao { + + /** + * One invoice row as the portal needs it. Money is in minor units of {@code currency} (e.g. + * cents for USD). {@code hostedInvoiceUrl} and {@code invoicePdf} are Stripe-hosted links that + * are stable for the lifetime of the invoice; safe to use as deep links from the UI. + * + *

    {@code description} is the product name from the subscription chain — the portal renders + * this as the row label (matching Stripe's customer-portal row layout). Falls back to the + * invoice's own {@code description} field, then to null when neither is set. + */ + public record InvoiceRow( + String id, + String number, + String status, + Long totalMinor, + String currency, + LocalDateTime createdAt, + LocalDateTime periodStart, + LocalDateTime periodEnd, + String hostedInvoiceUrl, + String invoicePdf, + String description, + /** Billed units (PDFs) on this invoice — summed line-item quantity; null if unknown. */ + Long pdfsProcessed) {} + + // Drafts are excluded: Stripe's API returns null for both + // {@code hosted_invoice_url} and {@code invoice_pdf} on unfinalized + // invoices, and Stripe's own customer portal hides drafts too — there's no + // user-facing artefact to surface yet. The next finalize / webhook flips + // the status and the invoice shows up automatically. + // + // The LATERAL join walks the same subscription → subscription_items → prices + // → products chain {@link StripeSubscriptionDao} uses to get the per-doc + // rate; here we use it to get the product NAME (e.g. "Stirling Processor + // Plan") so the portal can render Stripe's row label rather than the + // monospace invoice id. Falls back to {@code i.description}, then null. + private static final String QUERY = + "SELECT i.id, i.number, i.status::text AS status," + + " i.total, i.currency," + + " i.created, i.period_start, i.period_end," + + " i.hosted_invoice_url, i.invoice_pdf," + + " COALESCE(prod.name, i.description) AS description" + + " FROM stripe.invoices i" + + " LEFT JOIN LATERAL (" + + " SELECT si.price FROM stripe.subscription_items si" + + " WHERE si.subscription = i.subscription" + + " AND COALESCE(si.deleted, false) = false" + + " ORDER BY si.created DESC NULLS LAST LIMIT 1" + + " ) item ON true" + + " LEFT JOIN stripe.prices p ON p.id = item.price" + + " LEFT JOIN stripe.products prod ON prod.id = p.product" + + " WHERE i.customer = ?" + + " AND i.status::text <> 'draft'" + + " ORDER BY i.created DESC NULLS LAST" + + " LIMIT ?"; + + private final JdbcTemplate jdbcTemplate; + + public StripeInvoiceDao(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate"); + } + + /** + * The most recent {@code limit} invoices for {@code stripeCustomerId}, newest first. Empty list + * on missing schema / no rows / connectivity blip — the controller surfaces this as 200 with an + * empty body rather than 500. + */ + public List findRecentByCustomer(String stripeCustomerId, int limit) { + if (stripeCustomerId == null || stripeCustomerId.isBlank()) { + return List.of(); + } + int safeLimit = Math.max(1, Math.min(limit, 100)); + List rows; + try { + rows = + jdbcTemplate.query( + QUERY, + (rs, i) -> + new InvoiceRow( + rs.getString("id"), + rs.getString("number"), + rs.getString("status"), + nullableLong(rs, "total"), + rs.getString("currency"), + toLocal(rs.getLong("created"), rs.wasNull()), + toLocal(rs.getLong("period_start"), rs.wasNull()), + toLocal(rs.getLong("period_end"), rs.wasNull()), + rs.getString("hosted_invoice_url"), + rs.getString("invoice_pdf"), + rs.getString("description"), + null), + stripeCustomerId, + safeLimit); + } catch (DataAccessException e) { + log.warn( + "stripe.invoices lookup failed for customer {}: {}", + stripeCustomerId, + e.getMessage()); + return List.of(); + } + if (rows.isEmpty()) { + return rows; + } + Map billed = sumBilledUnits(rows.stream().map(InvoiceRow::id).toList()); + if (billed.isEmpty()) { + return rows; + } + return rows.stream() + .map( + r -> + new InvoiceRow( + r.id(), + r.number(), + r.status(), + r.totalMinor(), + r.currency(), + r.createdAt(), + r.periodStart(), + r.periodEnd(), + r.hostedInvoiceUrl(), + r.invoicePdf(), + r.description(), + billed.get(r.id()))) + .toList(); + } + + /** + * Sums billed quantity (PDFs) per invoice from the {@code stripe.invoices.lines} JSONB the Sync + * Engine mirrors — line items live in {@code lines->'data'}, NOT a separate {@code + * invoice_line_items} table (the sync engine never creates one). + * + *

    Only the metered usage line counts: a Processor invoice can also carry flat + * subscription-fee, proration and tax lines, each with its own {@code quantity}, so summing + * every line would inflate the headline PDF count (usage 500 + a fee line of 1 → "501"). We + * filter on {@code price.recurring.usage_type = 'metered'}. When no metered line is present the + * subquery is {@code NULL} and the invoice is omitted from the map, so {@code + * InvoiceRow.pdfsProcessed} stays {@code null} and the column renders "—" rather than "0". + * + *

    Run SEPARATELY from the invoice query and defensively wrapped, so a missing/changed schema + * degrades to an empty map (every row renders "—") instead of failing the whole invoice list. + */ + private Map sumBilledUnits(List invoiceIds) { + if (invoiceIds.isEmpty()) { + return Map.of(); + } + String placeholders = invoiceIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = + "SELECT i.id AS invoice_id," + + " (SELECT SUM((l->>'quantity')::int)" + + " FROM jsonb_array_elements(COALESCE(i.lines->'data', '[]'::jsonb)) AS l" + + " WHERE l->'price'->'recurring'->>'usage_type' = 'metered') AS qty" + + " FROM stripe.invoices i" + + " WHERE i.id IN (" + + placeholders + + ")"; + try { + Map map = new HashMap<>(); + jdbcTemplate.query( + sql, + (java.sql.ResultSet rs) -> { + long qty = rs.getLong("qty"); + if (!rs.wasNull()) { + // null (no metered line) → leave the key absent → renders "—". + map.put(rs.getString("invoice_id"), qty); + } + }, + invoiceIds.toArray()); + return map; + } catch (DataAccessException e) { + log.warn("stripe.invoices line-quantity sum failed: {}", e.getMessage()); + return Map.of(); + } + } + + private static Long nullableLong(java.sql.ResultSet rs, String column) + throws java.sql.SQLException { + long v = rs.getLong(column); + return rs.wasNull() ? null : v; + } + + private static LocalDateTime toLocal(long epochSeconds, boolean wasNull) { + if (wasNull) return null; + return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java new file mode 100644 index 0000000000..756e7dceb8 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java @@ -0,0 +1,91 @@ +package stirling.software.saas.payg.stripe; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import lombok.extern.slf4j.Slf4j; + +/** + * Read-only accessor for a team's default card off the Stripe Sync Engine schema ({@code + * stripe.payment_methods}). Prefers the customer's {@code invoice_settings.default_payment_method}; + * falls back to their most recently created card. Card details (brand / last4 / expiry) live in the + * {@code card} JSONB column the sync engine mirrors. + * + *

    Same defensive posture as {@link StripeInvoiceDao}/{@link StripeSubscriptionDao}: a missing + * schema or table — H2 unit tests, sync engine not provisioned, or {@code payment_methods} simply + * absent from the sync target list — degrades to {@link Optional#empty()} with a WARN, so the + * endpoint reports "no card on file" rather than 500ing the page. Editing always happens in + * Stripe's hosted portal; this never writes. + */ +@Slf4j +@Repository +@Profile("saas") +public class StripePaymentMethodDao { + + /** Card brand (e.g. "visa"), last 4 digits, and numeric expiry; any field may be null. */ + public record CardSummary(String brand, String last4, Integer expMonth, Integer expYear) {} + + private static final String QUERY = + "SELECT pm.card->>'brand' AS brand, pm.card->>'last4' AS last4," + + " pm.card->>'exp_month' AS exp_month, pm.card->>'exp_year' AS exp_year" + + " FROM stripe.payment_methods pm" + + " WHERE pm.customer = ? AND pm.type = 'card'" + + " ORDER BY (pm.id = (" + + " SELECT c.invoice_settings->>'default_payment_method'" + + " FROM stripe.customers c WHERE c.id = ?" + + " )) DESC NULLS LAST, pm.created DESC NULLS LAST" + + " LIMIT 1"; + + private final JdbcTemplate jdbcTemplate; + + public StripePaymentMethodDao(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate"); + } + + /** The customer's default card; empty on missing schema / no card / connectivity blip. */ + public Optional findDefaultCard(String stripeCustomerId) { + if (stripeCustomerId == null || stripeCustomerId.isBlank()) { + return Optional.empty(); + } + try { + List rows = + jdbcTemplate.query( + QUERY, + (rs, i) -> + new CardSummary( + rs.getString("brand"), + rs.getString("last4"), + parseIntOrNull(rs, "exp_month"), + parseIntOrNull(rs, "exp_year")), + stripeCustomerId, + stripeCustomerId); + return rows.stream().filter(Objects::nonNull).findFirst(); + } catch (DataAccessException e) { + log.warn( + "stripe.payment_methods lookup failed for customer {}: {}", + stripeCustomerId, + e.getMessage()); + return Optional.empty(); + } + } + + private static Integer parseIntOrNull(ResultSet rs, String column) throws SQLException { + String raw = rs.getString(column); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Integer.valueOf(raw.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 967b725b5e..5fe27a11e8 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -10,6 +10,7 @@ import java.util.Locale; import java.util.Objects; import java.util.stream.Collectors; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -49,6 +50,7 @@ import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; +import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter; import stirling.software.saas.service.SaasTeamService; import stirling.software.saas.service.SupabaseUserService; @@ -80,7 +82,10 @@ public class SupabaseSecurityConfig { private long clockSkewSeconds; @Bean - SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) + SecurityFilterChain saasSecurityFilterChain( + HttpSecurity http, + JwtDecoder jwtDecoder, + ObjectProvider deviceCredentialFilterProvider) throws Exception { // CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in // Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no @@ -135,6 +140,16 @@ public class SupabaseSecurityConfig { .jwtAuthenticationConverter( SupabaseSecurityConfig ::toAuthentication))); + + // Device-credential auth for linked self-hosted instances (combined-billing Mode A). + // The filter bean exists only when stirling.billing.account-link.enabled=true; when off it + // is absent here, so the instance surface cannot authenticate at all until release. + DeviceCredentialAuthenticationFilter deviceFilter = + deviceCredentialFilterProvider.getIfAvailable(); + if (deviceFilter != null) { + http.addFilterBefore(deviceFilter, BearerTokenAuthenticationFilter.class); + } + return http.build(); } diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index e54aa00df2..6d3e42e806 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -19,6 +19,7 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.saas.accountlink.LinkedInstanceRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.model.TeamInvitation; @@ -45,6 +46,7 @@ public class SaasTeamService { private final UserRoleService userRoleService; private final SaasTeamExtensionService saasTeamExtensionService; private final SaasTeamExtensionsRepository saasTeamExtensionsRepository; + private final LinkedInstanceRepository linkedInstanceRepository; private final stirling.software.proprietary.security.service.UserService userService; public static final String DEFAULT_TEAM_NAME = "Default"; @@ -458,22 +460,42 @@ public class SaasTeamService { * accept. The message points them at the right remedy — cancel the plan if the team is paid, * otherwise transfer leadership first. * + *

    Linked self-hosted instances (combined-billing "Mode A") bind to a team via {@code + * linked_instance.team_id}, so they too orphan a team that is left memberless — a personal team + * that accept deletes, or a non-personal team left by its last leader. They're checked in that + * same orphaning branch (not for a non-leader leaving a team that lives on); the remedy is to + * revoke them. + * * @param user the user attempting to accept an invitation - * @throws IllegalStateException if accepting would orphan a team the user leads + * @throws IllegalStateException if accepting would orphan a team the user leads or its + * instances */ private void assertCanLeaveCurrentTeamsToJoinAnother(User user) { for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { Team team = membership.getTeam(); - if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) { - // Personal teams are deleted on accept; non-leaders leaving never orphans a team. + boolean personal = saasTeamExtensionService.isPersonal(team); + if (!personal && !membership.isLeader()) { + // A non-leader leaving a shared team never orphans it. continue; } - // Only reached for a non-personal team the user leads — at most one such team in the - // one-team-per-user model — so this count runs ~once, not per membership. - if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) { + if (!personal + && membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) + > 1) { // Another leader remains, so the team keeps an owner. continue; } + // Leaving here orphans the team: a personal team is deleted on accept; a non-personal + // team is being left by its last leader. Either way its linked self-hosted instances + // lose their billing team, so block until they're revoked. + if (linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(team.getId()) > 0) { + throw new IllegalStateException( + "Revoke linked self-hosted instances on this team before joining another" + + " team."); + } + if (personal) { + // Personal teams are disposable (deleted on accept) and never billed/shared. + continue; + } if (hasActivePaidSubscription(team)) { throw new IllegalStateException( "Your team has an active plan and you are its last leader. Cancel the plan" diff --git a/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql b/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql new file mode 100644 index 0000000000..9c975f1ce7 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql @@ -0,0 +1,44 @@ +-- Account-link instances. One row per self-hosted instance that has linked a SaaS account. +-- +-- Part of the combined-billing "Mode A" (connected self-hosted) flow: +-- 1. An admin signs into their SaaS account in the Stirling Portal via the Supabase JS SDK +-- (a short-lived Supabase JWT, refreshed client-side — it never reaches the server long-term). +-- 2. That JWT is used ONCE to call POST /api/v1/account-link/register, which mints a +-- device_id + device_secret bound to the admin's team. The secret is returned once and +-- stored only on the instance; we keep a SHA-256 hash here (the secret is high-entropy, +-- so an unsalted hash is sufficient — same posture as API keys). +-- 3. The instance authenticates all unattended metering / entitlement calls with that device +-- credential. No long-lived user JWT lives on the server side. +-- +-- Twin of supabase/migrations/20260619000000_account_link_instances.sql (Stirling-PDF-SaaS). +-- Inert until release: the AccountLinkController + device-credential filter are gated behind +-- stirling.billing.account-link.enabled (default off). The table itself is harmless additive. + +CREATE TABLE IF NOT EXISTS stirling_pdf.linked_instance ( + instance_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, + created_by_user_id BIGINT, + -- admin who registered the instance; informational only (no FK so a user delete never + -- cascades a working instance offline). + device_id VARCHAR(64) NOT NULL UNIQUE, + -- public, non-secret identifier the instance presents on every request. + device_secret_hash VARCHAR(64) NOT NULL, + -- SHA-256 hex of the device secret; the secret itself is never stored. + name VARCHAR(255), + -- operator-set display label (hostname etc.) for the "Linked instances" list. + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP, + -- stamped when the device credential last authenticated; powers staleness display. + revoked_at TIMESTAMP + -- NULL = active. Set on unlink/revoke; a revoked credential fails authentication. +); + +CREATE INDEX IF NOT EXISTS idx_linked_instance_team + ON stirling_pdf.linked_instance (team_id); + +COMMENT ON TABLE stirling_pdf.linked_instance IS + 'One row per self-hosted instance linked to a SaaS account (combined-billing Mode A). ' + 'device_id is the public identifier; device_secret_hash is the SHA-256 of the bearer ' + 'secret (returned once at registration, stored only on the instance). The instance ' + 'authenticates unattended metering / entitlement calls with this credential; revoked_at ' + 'IS NULL means active.'; diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java new file mode 100644 index 0000000000..0de790cac0 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java @@ -0,0 +1,169 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest; +import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Pure-Mockito unit tests for {@link AccountLinkController} — the leader-only auth ladder, and that + * the team is always derived from the caller's membership (never the request). Mirrors {@code + * PaygInvoicesControllerTest}'s static-mock of {@link AuthenticationUtils}. + */ +@ExtendWith(MockitoExtension.class) +class AccountLinkControllerTest { + + @Mock private AccountLinkService service; + @Mock private TeamMembershipRepository memberRepo; + @Mock private UserRepository userRepository; + + private AccountLinkController controller; + private Authentication auth; + + @BeforeEach + void setUp() { + controller = new AccountLinkController(service, memberRepo, userRepository); + auth = + new AnonymousAuthenticationToken( + "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + @Test + void register_unauthenticated_returns401() { + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenThrow(new SecurityException("not authenticated")); + + ResponseEntity resp = + controller.register(new RegisterRequest("host"), auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(service); + } + } + + @Test + void register_noMembership_returns403() { + User user = mockUser(42L); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of()); + + ResponseEntity resp = controller.register(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(service); + } + } + + @Test + void register_nonLeader_returns403() { + User user = mockUser(42L); + TeamMembership member = membership(7L, TeamRole.MEMBER); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member)); + + ResponseEntity resp = controller.register(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(service); + } + } + + @Test + void register_leader_mintsCredentialForCallerTeam() { + User user = mockUser(42L); + TeamMembership leader = membership(7L, TeamRole.LEADER); + when(service.register(7L, 42L, "host")) + .thenReturn( + new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host")); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); + + ResponseEntity resp = + controller.register(new RegisterRequest("host"), auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + RegisterResponse body = resp.getBody(); + assertThat(body).isNotNull(); + // Team comes from the caller's membership and is surfaced in the response. + assertThat(body.teamId()).isEqualTo(7L); + assertThat(body.instanceId()).isEqualTo(99L); + assertThat(body.deviceSecret()).isEqualTo("sec-x"); + } + } + + @Test + void revoke_leader_returns204WhenServiceRevokes() { + User user = mockUser(42L); + TeamMembership leader = membership(7L, TeamRole.LEADER); + when(service.revoke(7L, 11L)).thenReturn(true); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); + + ResponseEntity resp = controller.revoke(11L, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + } + + @Test + void revoke_leader_returns404WhenServiceReportsNotFound() { + User user = mockUser(42L); + TeamMembership leader = membership(7L, TeamRole.LEADER); + when(service.revoke(7L, 11L)).thenReturn(false); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); + + ResponseEntity resp = controller.revoke(11L, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + private static User mockUser(long id) { + User u = new User(); + u.setId(id); + return u; + } + + private static TeamMembership membership(long teamId, TeamRole role) { + Team team = new Team(); + team.setId(teamId); + TeamMembership tm = new TeamMembership(); + tm.setTeam(team); + tm.setRole(role); + return tm; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java new file mode 100644 index 0000000000..3d725b98d6 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java @@ -0,0 +1,102 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.saas.accountlink.AccountLinkService.RegisteredInstance; + +/** + * Pure-Mockito unit tests for {@link AccountLinkService}: register returns the plaintext secret + * once but persists only its hash, and revoke is team-scoped + idempotent — a caller can never + * revoke another team's instance. + */ +@ExtendWith(MockitoExtension.class) +class AccountLinkServiceTest { + + @Mock private LinkedInstanceRepository repo; + + private AccountLinkService service; + + @BeforeEach + void setUp() { + service = new AccountLinkService(repo); + } + + @Test + void register_returnsPlaintextSecretOnce_persistsOnlyHash() { + ArgumentCaptor captor = ArgumentCaptor.forClass(LinkedInstance.class); + + RegisteredInstance reg = service.register(42L, 7L, "host-a"); + + verify(repo).save(captor.capture()); + LinkedInstance saved = captor.getValue(); + assertThat(reg.deviceSecret()).isNotBlank(); + assertThat(reg.deviceId()).isEqualTo(saved.getDeviceId()); + assertThat(saved.getDeviceSecretHash()) + .isEqualTo(AccountLinkService.sha256Hex(reg.deviceSecret())) + .isNotEqualTo(reg.deviceSecret()); + assertThat(saved.getTeamId()).isEqualTo(42L); + assertThat(saved.getCreatedByUserId()).isEqualTo(7L); + assertThat(saved.getName()).isEqualTo("host-a"); + } + + @Test + void revoke_owningTeam_setsRevokedAtAndReturnsTrue() { + LinkedInstance inst = instance(11L, 42L, null); + when(repo.findById(11L)).thenReturn(Optional.of(inst)); + + assertThat(service.revoke(42L, 11L)).isTrue(); + assertThat(inst.getRevokedAt()).isNotNull(); + verify(repo).save(inst); + } + + @Test + void revoke_alreadyRevoked_isIdempotentAndDoesNotResave() { + LocalDateTime revoked = LocalDateTime.now().minusDays(1); + LinkedInstance inst = instance(11L, 42L, revoked); + when(repo.findById(11L)).thenReturn(Optional.of(inst)); + + assertThat(service.revoke(42L, 11L)).isTrue(); + assertThat(inst.getRevokedAt()).isEqualTo(revoked); + verify(repo, never()).save(any()); + } + + @Test + void revoke_otherTeamsInstance_returnsFalseAndDoesNotSave() { + LinkedInstance inst = instance(11L, 99L, null); + when(repo.findById(11L)).thenReturn(Optional.of(inst)); + + assertThat(service.revoke(42L, 11L)).isFalse(); + assertThat(inst.getRevokedAt()).isNull(); + verify(repo, never()).save(any()); + } + + @Test + void revoke_unknownInstance_returnsFalse() { + when(repo.findById(404L)).thenReturn(Optional.empty()); + + assertThat(service.revoke(42L, 404L)).isFalse(); + verify(repo, never()).save(any()); + } + + private static LinkedInstance instance(Long id, Long teamId, LocalDateTime revokedAt) { + LinkedInstance i = new LinkedInstance(); + i.setInstanceId(id); + i.setTeamId(teamId); + i.setRevokedAt(revokedAt); + return i; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java new file mode 100644 index 0000000000..5b42e41a49 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java @@ -0,0 +1,162 @@ +package stirling.software.saas.accountlink; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import jakarta.servlet.ServletException; + +@ExtendWith(MockitoExtension.class) +class DeviceCredentialAuthenticationFilterTest { + + @Mock private LinkedInstanceRepository repo; + + private DeviceCredentialAuthenticationFilter filter; + + @BeforeEach + void setUp() { + filter = new DeviceCredentialAuthenticationFilter(repo); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private static LinkedInstance instanceWithSecret(String secret) { + LinkedInstance i = new LinkedInstance(); + i.setInstanceId(1L); + i.setTeamId(42L); + i.setDeviceId("dev-1"); + i.setDeviceSecretHash(AccountLinkService.sha256Hex(secret)); + return i; + } + + private static MockHttpServletRequest instanceRequest(String deviceId, String secret) { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/instance/whoami"); + if (deviceId != null) { + req.addHeader("X-Device-Id", deviceId); + } + if (secret != null) { + req.addHeader("X-Device-Secret", secret); + } + return req; + } + + @Test + void validCredentialAuthenticatesAsInstanceBoundToTeam() throws ServletException, IOException { + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")) + .thenReturn(Optional.of(instanceWithSecret("s3cr3t"))); + + filter.doFilter( + instanceRequest("dev-1", "s3cr3t"), + new MockHttpServletResponse(), + new MockFilterChain()); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertInstanceOf(LinkedInstanceAuthenticationToken.class, auth); + LinkedInstanceAuthenticationToken token = (LinkedInstanceAuthenticationToken) auth; + assertEquals(42L, token.getTeamId()); + assertEquals(1L, token.getInstanceId()); + assertEquals( + "ROLE_LINKED_INSTANCE", token.getAuthorities().iterator().next().getAuthority()); + } + + @Test + void successfulAuthStampsLastSeen() throws ServletException, IOException { + LinkedInstance instance = instanceWithSecret("s3cr3t"); + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance)); + + filter.doFilter( + instanceRequest("dev-1", "s3cr3t"), + new MockHttpServletResponse(), + new MockFilterChain()); + + // Targeted single-column update (guarded by revoked_at IS NULL), not a full-entity save. + verify(repo).touchLastSeen(eq(1L), any(LocalDateTime.class)); + verify(repo, never()).save(any()); + } + + @Test + void lastSeenWriteFailureDoesNotBreakAuth() throws ServletException, IOException { + LinkedInstance instance = instanceWithSecret("s3cr3t"); + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance)); + doThrow(new RuntimeException("transient db")) + .when(repo) + .touchLastSeen(anyLong(), any(LocalDateTime.class)); + + // A liveness-write failure must NOT propagate — auth is already set, so the + // request stays authenticated rather than 500ing. + filter.doFilter( + instanceRequest("dev-1", "s3cr3t"), + new MockHttpServletResponse(), + new MockFilterChain()); + + assertInstanceOf( + LinkedInstanceAuthenticationToken.class, + SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void wrongSecretDoesNotAuthenticate() throws ServletException, IOException { + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")) + .thenReturn(Optional.of(instanceWithSecret("right-secret"))); + + filter.doFilter( + instanceRequest("dev-1", "wrong-secret"), + new MockHttpServletResponse(), + new MockFilterChain()); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void unknownOrRevokedDeviceDoesNotAuthenticate() throws ServletException, IOException { + when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.empty()); + + filter.doFilter( + instanceRequest("dev-1", "whatever"), + new MockHttpServletResponse(), + new MockFilterChain()); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void nonInstancePathIsSkippedEntirely() throws ServletException, IOException { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payg/wallet"); + req.addHeader("X-Device-Id", "dev-1"); + req.addHeader("X-Device-Secret", "s3cr3t"); + + filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain()); + + // Path-scoped: the device credential never even reaches the repo on a non-instance path. + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verifyNoInteractions(repo); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java new file mode 100644 index 0000000000..d221ac8603 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java @@ -0,0 +1,190 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import stirling.software.saas.accountlink.InstanceController.EntitlementResponse; +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.entitlement.EntitlementService; +import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +/** + * Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read. + * The team is resolved from the {@link LinkedInstanceAuthenticationToken} principal, never a path + * or body, and the minimal DTO maps straight off the billing context + entitlement snapshot. + */ +@ExtendWith(MockitoExtension.class) +class InstanceControllerTest { + + @Mock private EntitlementService entitlementService; + @Mock private TeamBillingService billingService; + @Mock private AccountLinkService accountLinkService; + + private InstanceController controller() { + return new InstanceController(entitlementService, billingService, accountLinkService); + } + + @Test + void entitlement_resolvesTeamFromTokenAndMapsSnapshot() { + Authentication token = new LinkedInstanceAuthenticationToken(1L, 42L); + when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L)); + when(entitlementService.getSnapshot(42L)) + .thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L)); + + ResponseEntity resp = controller().entitlement(token); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + EntitlementResponse body = resp.getBody(); + assertThat(body).isNotNull(); + assertThat(body.subscribed()).isTrue(); + assertThat(body.freeRemainingUnits()).isEqualTo(120L); + assertThat(body.periodSpendUnits()).isEqualTo(90L); + assertThat(body.periodCapUnits()).isEqualTo(1250L); + // WARNED is still within budget for the gate's purposes → coarse OK. + assertThat(body.state()).isEqualTo("OK"); + } + + @Test + void entitlement_uncapped_returnsNullCapUnits() { + Authentication token = new LinkedInstanceAuthenticationToken(2L, 7L); + when(billingService.forTeam(7L)).thenReturn(freeBilling(500L)); + when(entitlementService.getSnapshot(7L)) + .thenReturn(snapshot(EntitlementState.FULL, 0L, null)); + + ResponseEntity resp = controller().entitlement(token); + + EntitlementResponse body = resp.getBody(); + assertThat(body).isNotNull(); + assertThat(body.subscribed()).isFalse(); + assertThat(body.freeRemainingUnits()).isEqualTo(500L); + assertThat(body.periodCapUnits()).isNull(); + assertThat(body.state()).isEqualTo("OK"); + } + + @Test + void entitlement_degradedMapsToOverLimit() { + // The instance gate parses OK / OVER_LIMIT, never the SaaS FULL/WARNED/DEGRADED enum. + // DEGRADED (automation + AI gated) must reach the wire as OVER_LIMIT. + Authentication token = new LinkedInstanceAuthenticationToken(3L, 8L); + when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L)); + when(entitlementService.getSnapshot(8L)) + .thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L)); + + EntitlementResponse body = controller().entitlement(token).getBody(); + + assertThat(body).isNotNull(); + assertThat(body.state()).isEqualTo("OVER_LIMIT"); + } + + @Test + void entitlement_nonInstancePrincipalIsRejected() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller().entitlement(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(entitlementService, billingService); + } + + @Test + void revokeSelf_callsServiceWithTokenIdentityAndReturns204() { + Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L); + + ResponseEntity resp = controller().revokeSelf(token); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(accountLinkService).revoke(22L, 11L); + } + + @Test + void revokeSelf_rejectsNonInstancePrincipal() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller().revokeSelf(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(accountLinkService); + } + + @Test + void whoami_returnsResolvedInstanceAndTeam() { + Authentication token = new LinkedInstanceAuthenticationToken(5L, 9L); + + ResponseEntity resp = controller().whoami(token); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody().instanceId()).isEqualTo(5L); + assertThat(resp.getBody().teamId()).isEqualTo(9L); + } + + private static TeamBillingContext freeBilling(long freeRemaining) { + LocalDateTime start = LocalDateTime.now().withDayOfMonth(1); + return new TeamBillingContext( + false, + null, + start, + start.plusMonths(1), + freeRemaining, + freeRemaining, + null, + null, + null, + null); + } + + private static TeamBillingContext subscribedBilling(String subId, long freeRemaining) { + LocalDateTime start = LocalDateTime.now().withDayOfMonth(1); + return new TeamBillingContext( + true, + subId, + start, + start.plusMonths(1), + 500L, + freeRemaining, + BigDecimal.valueOf(2), + "usd", + 2500L, + 1250L); + } + + private static EntitlementSnapshot snapshot(EntitlementState state, long spend, Long cap) { + LocalDateTime start = LocalDateTime.now().withDayOfMonth(1); + return new EntitlementSnapshot( + state, + FeatureSet.FULL, + List.of(FeatureGate.OFFSITE_PROCESSING), + spend, + cap, + start, + start.plusMonths(1), + false); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java new file mode 100644 index 0000000000..9266b02336 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java @@ -0,0 +1,189 @@ +package stirling.software.saas.payg.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.api.PaygInvoicesController.InvoiceResponse; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.stripe.StripeInvoiceDao; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Pure-Mockito unit tests for {@link PaygInvoicesController}. Confirms team is resolved from the + * authenticated principal (never request), and the empty-list degrade paths (no team, no Stripe + * customer, no rows) all return 200 + [] rather than 4xx/5xx. + */ +@ExtendWith(MockitoExtension.class) +class PaygInvoicesControllerTest { + + @Mock private StripeInvoiceDao invoiceDao; + @Mock private PaygTeamExtensionsRepository extRepo; + @Mock private TeamMembershipRepository memberRepo; + @Mock private UserRepository userRepository; + + private PaygInvoicesController controller; + private Authentication auth; + + @BeforeEach + void setUp() { + controller = new PaygInvoicesController(invoiceDao, extRepo, memberRepo, userRepository); + auth = + new AnonymousAuthenticationToken( + "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + @Test + void list_unauthenticated_returns401() { + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenThrow(new SecurityException("not authenticated")); + + ResponseEntity> resp = controller.list(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(invoiceDao, extRepo, memberRepo); + } + } + + @Test + void list_noTeam_returnsEmpty() { + User user = mockUser(42L); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of()); + + ResponseEntity> resp = controller.list(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEmpty(); + verifyNoInteractions(invoiceDao, extRepo); + } + } + + @Test + void list_noStripeCustomer_returnsEmpty() { + User user = mockUser(42L); + TeamMembership tm = mockMembership(7L); + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm)); + when(extRepo.findById(7L)).thenReturn(Optional.empty()); + + ResponseEntity> resp = controller.list(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEmpty(); + verifyNoInteractions(invoiceDao); + } + } + + @Test + void list_mapsRowsAndClampsLimit() { + User user = mockUser(42L); + TeamMembership tm = mockMembership(7L); + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(7L); + ext.setStripeCustomerId("cus_abc"); + + StripeInvoiceDao.InvoiceRow row = + new StripeInvoiceDao.InvoiceRow( + "in_1", + "STIR-0001", + "paid", + 2500L, + "usd", + LocalDateTime.of(2026, 6, 1, 10, 0), + LocalDateTime.of(2026, 5, 1, 0, 0), + LocalDateTime.of(2026, 5, 31, 23, 59), + "https://stripe/invoice/1", + "https://stripe/invoice/1.pdf", + "Stirling Processor Plan", + 50000L); + + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm)); + when(extRepo.findById(7L)).thenReturn(Optional.of(ext)); + // 1000 should clamp to MAX_LIMIT (100) inside the controller. + when(invoiceDao.findRecentByCustomer(eq("cus_abc"), eq(100))).thenReturn(List.of(row)); + + ResponseEntity> resp = controller.list(1000, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).hasSize(1); + InvoiceResponse body = resp.getBody().get(0); + assertThat(body.id()).isEqualTo("in_1"); + assertThat(body.number()).isEqualTo("STIR-0001"); + assertThat(body.status()).isEqualTo("paid"); + assertThat(body.totalMinor()).isEqualTo(2500L); + assertThat(body.currency()).isEqualTo("usd"); + assertThat(body.hostedInvoiceUrl()).isEqualTo("https://stripe/invoice/1"); + assertThat(body.description()).isEqualTo("Stirling Processor Plan"); + assertThat(body.pdfsProcessed()).isEqualTo(50000L); + } + } + + @Test + void list_emptyDaoResult_returnsEmpty() { + User user = mockUser(42L); + TeamMembership tm = mockMembership(7L); + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(7L); + ext.setStripeCustomerId("cus_xyz"); + + try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { + mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .thenReturn(user); + when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm)); + when(extRepo.findById(7L)).thenReturn(Optional.of(ext)); + when(invoiceDao.findRecentByCustomer(anyString(), anyInt())).thenReturn(List.of()); + + ResponseEntity> resp = controller.list(null, auth); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEmpty(); + } + } + + private static User mockUser(long id) { + User u = new User(); + u.setId(id); + return u; + } + + private static TeamMembership mockMembership(long teamId) { + Team team = new Team(); + team.setId(teamId); + TeamMembership tm = new TeamMembership(); + tm.setTeam(team); + return tm; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java new file mode 100644 index 0000000000..c4c0780b86 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java @@ -0,0 +1,184 @@ +package stirling.software.saas.payg.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.api.PaygPaymentMethodController.PaymentMethodResponse; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.stripe.StripePaymentMethodDao; +import stirling.software.saas.payg.stripe.StripePaymentMethodDao.CardSummary; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.security.EnhancedJwtAuthenticationToken; + +/** + * Pure-Mockito unit tests for {@link PaygPaymentMethodController}: the auth/team-resolution and + * defensive-degrade branches, plus the happy path mapping a DAO {@link CardSummary} to the trimmed + * response. + */ +@ExtendWith(MockitoExtension.class) +class PaygPaymentMethodControllerTest { + + @Mock private StripePaymentMethodDao paymentMethodDao; + @Mock private PaygTeamExtensionsRepository extRepo; + @Mock private TeamMembershipRepository memberRepo; + @Mock private UserRepository userRepository; + + private PaygPaymentMethodController controller; + + @BeforeEach + void setUp() { + controller = + new PaygPaymentMethodController( + paymentMethodDao, extRepo, memberRepo, userRepository); + } + + @Test + void anonymousIsRejected() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller.get(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(paymentMethodDao, extRepo, memberRepo); + } + + @Test + void noTeam_returnsAbsent() { + User user = userWithId(5L, UUID.randomUUID()); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(5L)).thenReturn(List.of()); + + ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody().present()).isFalse(); + verifyNoInteractions(paymentMethodDao); + } + + @Test + void noStripeCustomer_returnsAbsent() { + User user = userWithId(6L, UUID.randomUUID()); + Team team = teamWithId(60L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(6L)) + .thenReturn(List.of(membership(team, user, TeamRole.LEADER))); + PaygTeamExtensions ext = mock(PaygTeamExtensions.class); + when(ext.getStripeCustomerId()).thenReturn(null); + when(extRepo.findById(60L)).thenReturn(Optional.of(ext)); + + ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody().present()).isFalse(); + verifyNoInteractions(paymentMethodDao); + } + + @Test + void cardOnFile_returnsPresentWithFields() { + User user = userWithId(7L, UUID.randomUUID()); + Team team = teamWithId(70L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(7L)) + .thenReturn(List.of(membership(team, user, TeamRole.LEADER))); + PaygTeamExtensions ext = mock(PaygTeamExtensions.class); + when(ext.getStripeCustomerId()).thenReturn("cus_123"); + when(extRepo.findById(70L)).thenReturn(Optional.of(ext)); + when(paymentMethodDao.findDefaultCard("cus_123")) + .thenReturn(Optional.of(new CardSummary("visa", "4242", 8, 2027))); + + ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId())); + + PaymentMethodResponse body = resp.getBody(); + assertThat(body).isNotNull(); + assertThat(body.present()).isTrue(); + assertThat(body.brand()).isEqualTo("visa"); + assertThat(body.last4()).isEqualTo("4242"); + assertThat(body.expMonth()).isEqualTo(8); + assertThat(body.expYear()).isEqualTo(2027); + } + + @Test + void mirrorMissingCard_returnsAbsent() { + User user = userWithId(8L, UUID.randomUUID()); + Team team = teamWithId(80L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(8L)) + .thenReturn(List.of(membership(team, user, TeamRole.LEADER))); + PaygTeamExtensions ext = mock(PaygTeamExtensions.class); + when(ext.getStripeCustomerId()).thenReturn("cus_456"); + when(extRepo.findById(80L)).thenReturn(Optional.of(ext)); + when(paymentMethodDao.findDefaultCard("cus_456")).thenReturn(Optional.empty()); + + ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getBody()).isNotNull(); + assertThat(resp.getBody().present()).isFalse(); + } + + // ----------------------------------------------------------------------------------------- + // Fixtures (mirroring PaygWalletControllerTest) + // ----------------------------------------------------------------------------------------- + + private static User userWithId(Long id, UUID supabaseId) { + User u = new User(); + u.setId(id); + u.setSupabaseId(supabaseId); + return u; + } + + private static Team teamWithId(Long id) { + Team t = new Team(); + t.setId(id); + t.setName("t-" + id); + return t; + } + + private static TeamMembership membership(Team team, User user, TeamRole role) { + TeamMembership m = new TeamMembership(); + m.setTeam(team); + m.setUser(user); + m.setRole(role); + return m; + } + + private static Authentication jwtAuth(UUID supabaseId) { + Jwt jwt = + Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", supabaseId.toString()) + .claim("email", "user@example.com") + .build(); + return new EnhancedJwtAuthenticationToken( + jwt, List.of(), "user@example.com", supabaseId.toString()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java index 2118f85794..39c244f762 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java @@ -29,10 +29,22 @@ class CapEvaluatorTest { } @Test - void zeroCap_treatedAsUnlimitedForSafety() { - // Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL). + void zeroCap_blocksMeteredWork() { + // An explicit $0 cap buys zero paid documents → metered work is blocked + // (DEGRADED/MINIMAL); only the free grant + manual tools run. (Uncapped is the + // separate capUnits==null case, covered by nullCap_returnsFullStateAndFullGates.) Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL); - assertThat(e.state()).isEqualTo(EntitlementState.FULL); + assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); + assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL); + assertThat(e.enabledGates()) + .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); + } + + @Test + void zeroCap_blocksEvenAtZeroSpend() { + // A $0 cap blocks from the first metered op — not gated on spend. + Evaluation e = CapEvaluator.evaluate(0L, 0L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); } @Test diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java index 1aa9e783b6..54b6e56391 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java @@ -243,6 +243,7 @@ class SupabaseSecurityConfigMoreTest { @Test @DisplayName("builds and returns the SecurityFilterChain from http.build()") + @SuppressWarnings("unchecked") void buildsFilterChain() throws Exception { HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); // http.build() returns DefaultSecurityFilterChain, so stub with that concrete type. @@ -250,8 +251,16 @@ class SupabaseSecurityConfigMoreTest { mock(org.springframework.security.web.DefaultSecurityFilterChain.class); when(http.build()).thenReturn(built); + // Device-credential filter is wired via an ObjectProvider; getIfAvailable() returns + // null here, so the optional filter is simply not added (fine for a build-only check). + org.springframework.beans.factory.ObjectProvider< + stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter> + deviceFilterProvider = + mock(org.springframework.beans.factory.ObjectProvider.class); + SecurityFilterChain result = - config(new ApplicationProperties()).saasSecurityFilterChain(http, jwtDecoder); + config(new ApplicationProperties()) + .saasSecurityFilterChain(http, jwtDecoder, deviceFilterProvider); assertThat(result).isSameAs(built); } diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java index 31c4f4be5c..ac2a4cecba 100644 --- a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java @@ -23,6 +23,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import stirling.software.common.model.enumeration.InvitationStatus; import stirling.software.common.model.enumeration.Role; @@ -32,6 +34,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.saas.accountlink.LinkedInstanceRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.model.TeamInvitation; @@ -62,6 +65,7 @@ class SaasTeamServiceTest { @Mock private UserRoleService userRoleService; @Mock private SaasTeamExtensionService saasTeamExtensionService; @Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository; + @Mock private LinkedInstanceRepository linkedInstanceRepository; @Mock private stirling.software.proprietary.security.service.UserService userService; @InjectMocks private SaasTeamService service; @@ -1383,4 +1387,92 @@ class SaasTeamServiceTest { return saved; }); } + + /** + * acceptInvitation's orphan guard against linked self-hosted instances (combined-billing "Mode + * A"). The guard ({@code assertCanLeaveCurrentTeamsToJoinAnother}) is private; it's exercised + * through its only caller up to the point where a team with active linked instances must block + * the move. LENIENT because the pass-through case stubs the full leave/join path while the + * blocking case short-circuits before reaching all of it. + */ + @Nested + @DisplayName("acceptInvitation - linked self-hosted instance orphan guard") + @MockitoSettings(strictness = Strictness.LENIENT) + class AcceptInvitationLinkedInstanceGuard { + + private static final long USER_ID = 7L; + private static final long OLD_TEAM_ID = 100L; + private static final long NEW_TEAM_ID = 200L; + private static final String TOKEN = "tok-1"; + private static final String EMAIL = "joiner@example.com"; + + @Test + @DisplayName("blocks accept when the current team has active linked instances") + void blocksWhenCurrentTeamHasActiveLinkedInstances() { + User joiner = user(USER_ID, EMAIL, EMAIL); + Team oldTeam = team(OLD_TEAM_ID, "old-team"); + Team newTeam = team(NEW_TEAM_ID, "new-team"); + TeamInvitation invitation = pendingInvitation(newTeam, joiner); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner)); + when(invitationRepository.findByInvitationToken(TOKEN)) + .thenReturn(Optional.of(invitation)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(USER_ID)) + .thenReturn(List.of(membership(oldTeam, joiner, TeamRole.LEADER))); + when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID)) + .thenReturn(1L); + + assertThatThrownBy(() -> service.acceptInvitation(TOKEN, joiner)) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Revoke linked self-hosted instances on this team before joining another" + + " team."); + + // Guard fires before any team mutation. + verify(membershipRepository, never()).delete(any()); + verify(userRepository, never()).updateUserTeamId(anyLong(), anyLong()); + } + + @Test + @DisplayName("lets accept through when the current team has no linked instances") + void passesGuardWhenNoLinkedInstances() { + User joiner = user(USER_ID, EMAIL, EMAIL); + Team oldTeam = team(OLD_TEAM_ID, "old-team"); + Team newTeam = team(NEW_TEAM_ID, "new-team"); + TeamInvitation invitation = pendingInvitation(newTeam, joiner); + TeamMembership oldMembership = membership(oldTeam, joiner, TeamRole.LEADER); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner)); + when(invitationRepository.findByInvitationToken(TOKEN)) + .thenReturn(Optional.of(invitation)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(USER_ID)).thenReturn(List.of(oldMembership)); + when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID)) + .thenReturn(0L); + // Personal old team → guard skips the last-leader check and leave/join proceeds. + when(saasTeamExtensionService.isPersonal(oldTeam)).thenReturn(true); + when(membershipRepository.countByTeamId(OLD_TEAM_ID)).thenReturn(0L); + when(saasTeamExtensionsRepository.incrementSeatsUsed(NEW_TEAM_ID)).thenReturn(1); + + service.acceptInvitation(TOKEN, joiner); + + // Guard let the move through: the old membership was left and the user re-pointed. + verify(membershipRepository).delete(oldMembership); + verify(userRepository).updateUserTeamId(USER_ID, NEW_TEAM_ID); + verify(invitationRepository).save(invitation); + assertThat(invitation.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); + } + + private TeamInvitation pendingInvitation(Team team, User invitee) { + TeamInvitation inv = new TeamInvitation(); + inv.setTeam(team); + inv.setInviter(invitee); + inv.setInviteeEmail(invitee.getEmail()); + inv.setStatus(InvitationStatus.PENDING); + inv.setInvitationToken(TOKEN); + inv.setExpiresAt(LocalDateTime.now().plusDays(1)); + return inv; + } + } } diff --git a/frontend/.gitignore b/frontend/.gitignore index 6d958ac439..3a5de11c57 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -24,10 +24,10 @@ /editor/.env.local /editor/.env.*.local -# Root .gitignore ignores all .env* - whitelist our committed ones here -!.env -!.env.desktop -!.env.saas +# Root .gitignore ignores all .env* - whitelist only our committed ones, anchored +# to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS +# mock env is injected via .storybook/main.ts, not a file). +!/portal/.env !/editor/.env !/editor/.env.desktop !/editor/.env.saas diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 0797a367a3..c016a5c37d 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -51,6 +51,16 @@ const config: StorybookConfig = { ], }), ); + // Point apiClient.saas at a mock origin so the SaaS-backed billing stories + // (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and + // their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host + // never receives a real request — MSW answers first. Injected here, next to the + // MSW setup, rather than via a frontend/.env so no stray env file can leak into a + // real portal/editor build (those load env from their own roots). + config.define = { + ...(config.define ?? {}), + "import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"), + }; return config; }, }; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index c94b169737..1f9c935fe6 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -14,10 +14,12 @@ import { MantineProvider } from "@mantine/core"; void React; import { TierProvider, type Tier } from "@portal/contexts/TierContext"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; import { ThemeProvider } from "@portal/contexts/ThemeContext"; import { UIProvider } from "@portal/contexts/UIContext"; import { mantineTheme } from "@portal/theme/mantineTheme"; import { handlers } from "@portal/mocks/handlers"; +import { configureSupabase } from "@shared/auth/supabase/supabaseClient"; import "@mantine/core/styles.css"; import "@shared/tokens/tokens.css"; @@ -26,6 +28,27 @@ import "@shared/tokens/base.css"; // Start MSW once. Storybook runs in a browser so this uses the service worker. initialize({ onUnhandledRequest: "bypass" }, handlers); +// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment +// method, wallet) clear the session check and reach the MSW handlers instead of +// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally +// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only +// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via +// .storybook/main.ts's viteFinal define, not a frontend/.env file. +const saasStub = configureSupabase({ + url: "http://saas.mock", + key: "storybook-anon-key", + authOptions: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, +}); +saasStub.auth.getSession = async () => + ({ + data: { session: { access_token: "storybook-fake-jwt" } }, + error: null, + }) as Awaited>; + /** * Bridge between Storybook's `tier` global toolbar and the actual TierProvider. * Without this the toolbar would just change a label; with it, every story @@ -67,6 +90,8 @@ function ThemeWatcher() { const withProviders: Decorator = (Story, context) => { const tier = (context.globals.tier as Tier) ?? "pro"; + const linkState = + (context.globals.linkState as LinkState) ?? "linked-subscribed"; // withThemeByDataAttribute exposes the toolbar theme as the `theme` global. // Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings, // default surfaces) follows the dark toggle alongside the SUI CSS variables. @@ -78,12 +103,16 @@ const withProviders: Decorator = (Story, context) => { - - - - - - + {/* LinkProvider must wrap TierProvider: TierContext derives its tier + from useLink() (matches App.tsx's nesting). */} + + + + + + + + @@ -128,6 +157,20 @@ const preview: Preview = { dynamicTitle: true, }, }, + linkState: { + name: "Link", + description: "Account-link state — drives useLink() everywhere", + defaultValue: "linked-subscribed", + toolbar: { + icon: "link", + items: [ + { value: "unlinked", title: "Unlinked" }, + { value: "linked-free", title: "Linked · Free" }, + { value: "linked-subscribed", title: "Linked · PAYG" }, + ], + dynamicTitle: true, + }, + }, }, decorators: [ withProviders, diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx index bee9d0474d..f26d40f743 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx @@ -1,250 +1,59 @@ /** - * 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. - * - *

    Currency-agnostic by design

    - * - * 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. - * - *

    Controlled

    - * - * 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}. + * Editor cloud adapter over the shared {@code @shared/billing} spend-cap control: + * supplies the i18n copy (the shared control is copy-agnostic) and the editor's + * {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code + * onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is + * unchanged, so the plan-page cap editor and the upgrade-checkout flow keep + * consuming it as before. */ -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 React from "react"; import { useTranslation } from "react-i18next"; +import { + DEFAULT_CAP_PRESETS, + SpendCapControl as SharedSpendCapControl, +} from "@shared/billing"; // 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 { DEFAULT_CAP_PRESETS }; 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; - /** 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 = ({ - capUsd, - onChange, - pricePerDocMinor, - currency, - presets = DEFAULT_CAP_PRESETS, - onSave, saveLabel, - savedCapUsd, - note, + ...rest }) => { 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( - 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 ( -
    -
    - {presets.map((preset) => ( - - ))} - - {/* Custom-entry pill — dashed until it carries a value, then it fills - like a selected chip. */} - - - - - {onSave && ( - - )} -
    - - {previewDocs != null && ( -
    - -
    -
    - {t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { - docs: previewDocs.toLocaleString(), - })} -
    -
    - {t("payg.cap.docsRate", "at {{rate}} / PDF", { - rate: formatMinor(pricePerDocMinor ?? 0, currency), - })} -
    -
    -
    - )} - - {isNoCap && ( -
    - {t( - "payg.cap.noCapDesc", - "Usage is billed without an upper limit. You can re-enable a cap at any time.", - )} -
    - )} - - {note &&
    {note}
    } -
    + + t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { + docs, + }), + docsRate: (rate) => + t("payg.cap.docsRate", "at {{rate}} / PDF", { rate }), + noCapDesc: t( + "payg.cap.noCapDesc", + "Usage is billed without an upper limit. You can re-enable a cap at any time.", + ), + }} + /> ); }; diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index f1012bd2dd..308fc97d32 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -7,36 +7,10 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useWallet, type Wallet } from "@app/hooks/useWallet"; +import { currencySymbol, MeterBar, meterState } from "@shared/billing"; import "@app/components/shared/config/configSections/Payg.css"; import "@app/components/shared/config/configSections/PaygFree.css"; -export type MeterState = "FULL" | "WARNED" | "DEGRADED"; - -/** Warn/degrade band for a usage meter (mirrors the BE thresholds). */ -export 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 }; -} - -/** 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() + " " : "$"; - } -} - // ─── One-time free grant meter ────────────────────────────────────────────── export interface FreeSnapshot { @@ -78,38 +52,20 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { : t("payg.free.state.plentyLeft", "Plenty left"); return ( -
    -
    -
    - - {snap.billableUsed.toLocaleString()} - - - {t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", { - limit: snap.billableLimit.toLocaleString(), - })} - -
    - - - {stateLabel} - -
    - -
    -
    -
    - -
    + {t("payg.free.hero.metaCategories", "Automation · AI · API requests")} -
    -
    + } + /> ); } @@ -158,45 +114,28 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) { const symbol = currencySymbol(snap.currency); return ( -
    -
    -
    - - {symbol} - {snap.spent.toLocaleString()} + + + {t( + "payg.spendCapMeter.metaCategories", + "Automation · AI · API spend", + )} - - {t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", { - amount: `${symbol}${snap.cap.toLocaleString()}`, - })} + + + {t("payg.spendCapMeter.resets", "Resets each billing period")} -
    - - - {stateLabel} - -
    - -
    -
    -
    - -
    - - {t( - "payg.spendCapMeter.metaCategories", - "Automation · AI · API spend", - )} - - - - {t("payg.spendCapMeter.resets", "Resets each billing period")} - -
    -
    + + } + /> ); } diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 0222cc77c0..5a1b1d21cb 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -50,123 +50,26 @@ import apiClient from "@app/services/apiClient"; import { createPortalSession } from "@app/services/billing"; import { openExternal } from "@app/platform/openExternal"; import { getWalletDevPreview } from "@app/hooks/walletDevPreview"; +import type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +} from "@shared/billing"; // ─── 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} - * because the activity-row shape is not yet finalised — when the meter- - * event surface lands, this widens to a real interface. - */ - recent: Array>; -} +// The wallet contract lives in @shared/billing (shared with the admin portal). +// Re-exported so existing `@app/hooks/useWallet` importers keep their imports. +export type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +}; export interface UseWalletResult { wallet: Wallet | null; diff --git a/frontend/editor/src/saas/routes/authShared/saas-auth.css b/frontend/editor/src/saas/routes/authShared/saas-auth.css index 505b29a14a..535d72d3dd 100644 --- a/frontend/editor/src/saas/routes/authShared/saas-auth.css +++ b/frontend/editor/src/saas/routes/authShared/saas-auth.css @@ -1,55 +1,3 @@ -/* SaaS-specific auth styles — imported alongside the base auth.css */ - -.oauth-container-fullwidth { - display: flex; - flex-direction: column; - gap: 0.75rem; /* 12px */ -} - -.oauth-button-fullwidth { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - padding: 0.75rem 1rem; - border: 1px solid #d1d5db; - border-radius: 100px; - background-color: #ffffff; - font-size: 1rem; - font-weight: 600; - color: #000000; - cursor: pointer; - gap: 0.5rem; - box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); - transition: - background-color 150ms ease, - box-shadow 150ms ease, - border-color 150ms ease; -} - -.oauth-button-fullwidth:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.oauth-button-fullwidth:hover:not(:disabled) { - background-color: #fafafa; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); -} - -[data-mantine-color-scheme="dark"] .oauth-button-fullwidth { - background-color: var(--bg-surface); - color: var(--text-primary); - border-color: var(--border-default); - box-shadow: none; -} - -[data-mantine-color-scheme="dark"] - .oauth-button-fullwidth:hover:not(:disabled) { - background-color: var(--bg-raised); - box-shadow: none; -} - .auth-dropdown-wrapper { position: relative; } diff --git a/frontend/portal/.env b/frontend/portal/.env index 52374a4aa2..d4cae761ad 100644 --- a/frontend/portal/.env +++ b/frontend/portal/.env @@ -10,3 +10,22 @@ VITE_EDITOR_URL=/ # in production builds). The single-origin proxy task sets this to "false" so the # portal uses the real backend. VITE_PORTAL_MOCKS= + +# Hosted SaaS Supabase project for IN-APP account linking (both values are +# public). Set per deploy; absent → the account-link UI shows a "configure" +# state. For local e2e, point these at the SaaS Supabase project the local +# backend links against (e.g. the V3 branch project). +VITE_SAAS_SUPABASE_URL= +VITE_SAAS_SUPABASE_ANON_KEY= + +# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used +# for ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the +# admin's Supabase JWT. Distinct from the local backend (which the portal +# reaches same-origin via the vite proxy). Absent → wallet/billing surfaces +# stay on the MSW mock. +VITE_SAAS_API_URL= + +# Stripe publishable key (pk_live_… / pk_test_…) used by the embedded Checkout +# in the billing surface. Public by design. Empty → the checkout modal shows a +# "configure" state instead of mounting Stripe. +VITE_STRIPE_PUBLISHABLE_KEY= diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index 1aa24bd89e..c0b3ccae91 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -49,8 +49,9 @@ appProcessor = "Processor" appEditor = "Editor" docsProcessed = "Docs processed" docsCount = "{{docs}} docs" -planPayAsYouGo = "Pay-as-you-go" -planEnterprise = "Enterprise Plan" +planProcessor = "Processor plan" +linkAccount = "Link Stirling account" +planEnterprise = "Enterprise plan" [search] ariaLabel = "Search" @@ -94,6 +95,7 @@ general = "General" authentication = "Authentication" sessions = "Active sessions" early-access = "Early access" +account-link = "Account link" [settings.profile] accountFallback = "Account" @@ -420,180 +422,6 @@ snapshot = "Snapshot: re-read the folder every run" [sources.types.unknown] label = "Source" -[usage] -title = "Usage & Billing" -subtitle = "Your last 30 days of processing, plan, and charges." - -[usage.chart.empty] -title = "No usage yet" -description = "Once documents are processed, your 30-day usage appears here." - -[usage.kpi.docsThisPeriod] -label = "Docs this period" -description = "of {{included}} included" - -[usage.kpi.costThisMonth] -label = "Cost this month" -description = "incl. {{fee}} platform" -freePlan = "free plan" - -[usage.kpi.nextBillingDate] -label = "Next billing date" -resetsMonthly = "resets monthly" -autoCharge = "auto-charge" - -[usage.kpi.remainingInPlan] -label = "Remaining in plan" -description = "docs before cap" - -[usage.kpi.commitUtilisation] -label = "Commit utilisation" -description = "of committed volume" - -[usage.kpi.overage] -label = "Overage (${{rate}}/doc)" -description_one = "{{docs}} doc past cap" -description_other = "{{docs}} docs past cap" - -[usage.currentPlan] -eyebrow = "Current plan" - -[usage.currentPlan.badge] -free = "Free" -pro = "Pay-as-you-go" -enterprise = "Committed" - -[usage.currentPlan.free] -progressLabel = "Free plan usage" - -[usage.currentPlan.free.capReached] -title = "You've hit your free plan cap" -body = "New documents are paused until next cycle. Upgrade to keep processing without interruption." - -[usage.currentPlan.free.approaching] -title = "Approaching your free plan cap" -body = "You're at {{pct}}% of 500 docs/month. Upgrade to pay-as-you-go to avoid a pause." - -[usage.currentPlan.pro] -platformFee = "Platform fee" -includedDocs = "Included docs" -overage = "Overage · {{docs}} docs @ ${{rate}}" -projected = "Projected this month" - -[usage.currentPlan.enterprise] -committedVolume = "Committed volume" -committedVolumeValue = "{{docs}} docs/mo" -drawnThisPeriod = "Drawn this period" -drawnThisPeriodValue = "{{docs}} docs" -effectiveRate = "Effective rate" -effectiveRateValue = "${{rate}} / doc" -monthlyDraw = "Monthly draw" - -[usage.currentPlan.actions] -upgrade = "Upgrade plan" -talkToSales = "Talk to sales" -adjustCommitment = "Adjust commitment" -downloadInvoices = "Download invoices" - -[usage.spendCap.free] -title = "Spend cap" -description = "The free plan can't accrue spend — your usage is hard-capped at 500 docs/month. Upgrade to pay-as-you-go to set a monthly spend cap." - -[usage.spendCap.enterprise] -title = "Spend controls" -description = "Spend is governed by your committed-volume contract. Overage terms and alert thresholds are managed with your account team." -badge = "Committed contract" -overage = "Overage billed at ${{rate}}/doc" - -[usage.spendCap.pro] -title = "Monthly spend cap" -subtitle = "Pause processing automatically when spend reaches your limit." -disable = "Disable cap" -enable = "Enable cap" -projected = "Projected {{projected}} of {{cap}} cap" -progressLabel = "Spend against cap" - -[usage.plans] -title = "Plans" -subtitle = "Move up or down at any time — changes take effect next cycle." - -[usage.planCard] -current = "Current" -yourPlan = "Your plan" -contactSales = "Contact sales" -choosePlan = "Choose plan" - -[usage.history] -title = "Billing history" -subtitle = "Line items from the current and prior billing cycles." -emptyRows = "No line items" - -[usage.history.columns] -date = "Date" -description = "Description" -docs = "Docs" -amount = "Amount" -status = "Status" - -[usage.history.status] -paid = "Paid" -due = "Due" -pending = "Pending" -refunded = "Refunded" - -[usage.history.empty] -title = "No billing history" -description = "Charges and credits appear here once your first cycle closes." - -[usage.upgrade] -notNow = "Not now" - -[usage.upgrade.free] -title = "Upgrade to keep processing" -subtitle = "Pay-as-you-go · $0.05 / doc" -body = "You're at the edge of the 500 doc/month free cap. Pay-as-you-go lifts the cap instantly — you only pay for what you process beyond the included 25,000 docs." -bullets = [ - "Lift the 500 doc/month cap immediately", - "25,000 docs included, then $0.05/doc", - "Unlimited pipelines, agents, and sources", - "Set a monthly spend cap to stay in control", -] -cta = "Switch to pay-as-you-go" - -[usage.upgrade.proToEnterprise] -title = "Move to a committed plan" -subtitle = "Enterprise · committed annual volume" -body = "Your overage is consistent month over month. A committed-volume contract lowers your effective per-doc rate and unlocks dedicated regions, SSO, and a named CSM." -bullets = [ - "Lower effective rate vs metered overage", - "Dedicated & on-prem region options", - "SSO, audit-log export, signed DPA", - "Named CSM and 99.99% SLA", -] -cta = "Talk to sales" - -[usage.upgrade.pro] -title = "You're already on pay-as-you-go" -subtitle = "Considering a committed plan?" -body = "Pay-as-you-go scales with usage. If your volume is steady, a committed-volume contract typically lowers your effective per-doc rate." -bullets = [ - "Predictable monthly spend", - "Lower effective per-doc rate at volume", - "Volume discounts kick in past 1M docs/mo", -] -cta = "Explore committed pricing" - -[usage.upgrade.enterprise] -title = "Adjust your commitment" -subtitle = "Enterprise · bespoke terms" -body = "Your plan is governed by a committed-volume contract. Changes to committed volume, regions, or terms are handled with your account team — they'll model the right shape with you." -bullets = [ - "Re-model committed volume up or down", - "Add dedicated or on-prem regions", - "Adjust SLA, DPA, and overage terms", -] -cta = "Contact your CSM" - [documents] title = "Documents" subtitle = "Review and approve documents moving through your pipelines." @@ -1774,3 +1602,252 @@ redirectingToEditor = "Redirecting to the editor..." title = "Something went wrong on this page" description = "This view hit an unexpected error. Try again, or pick another section from the sidebar." retry = "Try again" + +# ── Account link (combined-billing Mode A) ─────────────────────────────────── +[accountLink.state] +unlinked = "Not linked" +free = "Editor plan" +subscribed = "Processor plan" + +[accountLink.panel] +sub = "Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet." +instancesTitle = "Linked instances" +instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access." +revokeError = "Couldn't revoke instance" + +[accountLink.panel.loadError] +title = "Couldn't load linked instances" +forbidden = "Only the team owner can view the org's linked instances." +generic = "Couldn't load the team's linked instances. Try again in a moment." + +[accountLink.card] +eyebrow = "Account link" +title = "Link this org to its Stirling account" +linked = "Linked" +notLinked = "Not linked" +unlink = "Unlink" +linkButton = "Link your Stirling account" +linkedAs = "Linked as {{name}}." +linkedGeneric = "This instance is linked." +billingNote = "Unattended processing bills against your org wallet." + +[accountLink.card.error] +title = "Couldn't link" + +[accountLink.card.loginNotConfigured] +title = "SaaS login not configured" +before = "Set" +after = "to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog." + +[accountLink.modal] +linkTitle = "Link your Stirling account" +reauthTitle = "Sign in again" +linkSubtitle = "Sign in to the account this server should bill against." +reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked." +simulateSignIn = "Simulate sign-in (dev)" + +[accountLink.modal.loginNotConfigured] +title = "SaaS login not configured" +before = "Set" +and = "and" +after = "to enable in-app linking against the hosted Stirling account." + +[accountLink.gate] +title = "Link to unlock" +titleFeature = "Link to unlock {{feature}}" +description = "Link this org's Stirling account to use billable features." +action = "Link account" + +[accountLink.instances] +unnamed = "Unnamed instance" +revoked = "Revoked" +active = "Active" +revoke = "Revoke" + +[accountLink.instances.columns] +instance = "Instance" +status = "Status" +lastSeen = "Last seen" +linked = "Linked" + +[accountLink.instances.empty] +title = "No linked instances" +description = "Link this org's account, then register your self-hosted instances to see them here." + +[accountLink.instances.time] +never = "never" +justNow = "just now" +minutesAgo_one = "{{count}}m ago" +minutesAgo_other = "{{count}}m ago" +hoursAgo_one = "{{count}}h ago" +hoursAgo_other = "{{count}}h ago" +daysAgo_one = "{{count}}d ago" +daysAgo_other = "{{count}}d ago" + +# ── Billing surface (Usage & billing) ──────────────────────────────────────── +[billing.enterpriseUpsell] +eyebrow = "Volume discount · 1M+ PDFs" +title = "Stirling Enterprise" +description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships." +cta = "Build your Enterprise quote" + +[billing.freeEditors] +title = "Free PDF Editors" +previewBadge = "Preview · sample data" +subtitle = "Deploy anywhere, for your whole team." +editorsDeployed = "Editors deployed" +activeThisMonth = "Active this month" +pdfsEdited = "PDFs edited" +cost = "Cost" +inviteTeammates = "Invite teammates" + +[billing.freePlan] +currentPlan = "Current plan" +planName = "Editor" +freeForever = "Free forever" +ssoIncluded = "SSO included" +unlimitedUsers = "Unlimited users" +switchOnProcessor = "Switch on the Processor →" +noTeamResolved = "No team is resolved on your wallet yet — refresh and try again." +checkoutErrorTitle = "Couldn't start checkout" +ownerOnly = "Only the team owner can switch on the Processor plan." + +[billing.linkPrompt] +title = "Link your Stirling account" +description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use." +cta = "Link Stirling account" + +[billing.walletMeter] +eyebrow = "Processor trial" +sub = "Use the PDF Editor for free. Pay to process PDFs automatically." +title_one = "Process {{allowance}} PDFs free" +title_other = "Process {{allowance}} PDFs free" +titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +capSuffix_one = "of {{allowance}} free PDFs used" +capSuffix_other = "of {{allowance}} free PDFs used" +statusLabel_one = "{{remaining}} left" +statusLabel_other = "{{remaining}} left" + +[billing.pdfsProcessed] +eyebrow = "PDFs processed this period" +unit = "metered PDFs" +segbarAriaLabel = "Metered PDFs split by category" +segmentApiLabel = "API" +segmentApiDesc = "Direct API requests" +segmentAgentsLabel = "Agents" +segmentAgentsDesc = "AI agent actions" +segmentAutomationLabel = "Automation" +segmentAutomationDesc = "Automations & pipelines" +legendValue_one = "{{formatted}} PDFs" +legendValue_other = "{{formatted}} PDFs" +emptyPeriod = "No metered processing yet this period." + +[billing.spendThisMonth] +eyebrow = "Spend this month" +processed_one = "{{formattedCount}} PDF processed." +processed_other = "{{formattedCount}} PDFs processed." +processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." +processedWithRate_other = "{{formattedCount}} PDFs processed, at {{rate}} each." + +[billing.spendLimit] +eyebrow = "Spend limit" +editTitle = "Set your monthly ceiling" +capControlNote = "Changes apply immediately — raise or lower the ceiling any time." +useSuggested = "Use suggested · {{amount}} / month" +guardrailLabel = "Your guardrail:" +guardrailBody = "a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost." +saveError = "Couldn't save limit" +cancel = "Cancel" +save = "Save limit" +displaySub = "You're only billed for what you process automatically — never past the ceiling." +adjustLimit = "Adjust limit" +capSuffix = "/ month" +capSuffixWithDocs = "/ month · ≈ {{documents}} documents" +noCap = "no cap" +pctUsed = "{{pct}}% used" +usedThisMonth = "{{amount}} used this month" +remaining = "{{amount}} remaining" +thisPeriodUncapped = "{{amount}} this period · uncapped" + +[billing.spendLimit.projection] +label = "Projected to exceed." +body_one = "At {{rate}}/day you reach the cap in ~{{count}} day (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}." +body_other = "At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}." + +[billing.invoices] +title = "Invoice history" +columnDate = "Date" +columnPdfsProcessed = "PDFs processed" +columnAmount = "Amount" +columnStatus = "Status" +columnDescription = "Description" +descriptionFallback = "Invoice" +viewLink = "View ↗" +viewAriaLabel = "View invoice {{number}} in Stripe" +pdfLink = "PDF ↓" +downloadAriaLabel = "Download invoice {{number}} as PDF" +loadError = "Couldn't load invoices: {{error}}" +emptyTitle = "No invoices yet" +emptyDescription = "Once your team subscribes and the first cycle closes, your invoices appear here." +showFewer_one = "Show fewer (top {{count}})" +showFewer_other = "Show fewer (top {{count}})" +showMostRecent_one = "Show {{count}} most recent" +showMostRecent_other = "Show {{count}} most recent" +showAll_one = "Show all {{count}}" +showAll_other = "Show all {{count}}" +fetchLimitNote_one = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal." +fetchLimitNote_other = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal." + +[billing.paymentMethod] +eyebrow = "Payment method" +cardEnding = "{{brand}} ending {{last4}}" +cardFallback = "Card" +expiresBilledMonthly = "Expires {{expiry}} · billed monthly" +billedMonthly = "Billed monthly" +managedTitle = "Managed in Stripe" +managedSub = "Your card and billing details are kept securely in Stripe's customer portal." +update = "Update" + +[billing.checkout] +title = "Turn on the Processor plan" +subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest." +noClientSecret = "Edge function returned no client_secret." + +[billing.checkout.notConfigured] +title = "Stripe not configured" +bodyBefore = "Set" +bodyAfter = "in the portal env to enable in-app checkout." + +[billing.checkout.error] +title = "Couldn't start checkout" + +[billing.subscribedPlan.capWarn] +reachedTitle = "Monthly spend limit reached" +approachingTitle = "You're at {{pct}}% of your monthly spend limit" +raiseLimit = "Raise limit" +reachedBody = "Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working." +approachingBody = "Raise it now so automated processing never pauses." + +[billing.subscribedPlan.portalError] +title = "Couldn't open Stripe portal" + +# ── Usage & billing view ───────────────────────────────────────────────────── +[usage] +title = "Usage & billing" +subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console." +managePayment = "Manage Payment" + +[usage.finalizing] +title = "Finalizing your subscription…" +body = "It can take a few seconds for your subscription to activate. This page updates automatically." + +[usage.sessionExpired] +title = "Session expired" +action = "Sign in again" +body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked." + +[usage.error] +loadWallet = "Couldn't load wallet" +openStripePortal = "Couldn't open Stripe portal" +walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}" diff --git a/frontend/portal/src/App.tsx b/frontend/portal/src/App.tsx index a94a5c304b..cd1195cde3 100644 --- a/frontend/portal/src/App.tsx +++ b/frontend/portal/src/App.tsx @@ -5,6 +5,8 @@ import { AuthProvider } from "@shared/auth"; import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { TierProvider } from "@portal/contexts/TierContext"; +import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; +import type { SupabaseLoginSession } from "@shared/auth/ui/useSupabaseLogin"; import { UIProvider, useUI } from "@portal/contexts/UIContext"; import { mantineTheme } from "@portal/theme/mantineTheme"; import { AppShell } from "@portal/components/AppShell"; @@ -13,6 +15,11 @@ import { AssistantButton } from "@portal/components/AssistantButton"; import { AssistantPanel } from "@portal/components/AssistantPanel"; import { SearchModal } from "@portal/components/SearchModal"; import { SettingsModal } from "@portal/components/SettingsModal"; +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; +import { + AccountLinkProvider, + useAccountLinkContext, +} from "@portal/contexts/AccountLinkContext"; import { ViewRouter } from "@portal/ViewRouter"; /** @@ -58,8 +65,42 @@ function GlobalShortcuts() { /** Bridges the Settings modal's open/close props to UIContext state. */ function SettingsHost() { - const { settingsOpen, closeSettings } = useUI(); - return ; + const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); + return ( + + ); +} + +/** + * The one and only account-link login modal. Mounted at the app root (never + * nested in another overlay) and driven by UIContext, so any "Link account" CTA + * — sidebar, billing prompt, feature gate, Settings panel — opens this exact + * instance. Linking is finished by the shared {@link useAccountLinkContext} + * orchestration. + */ +function LinkModalHost() { + const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); + const { markSaasSessionChanged } = useLink(); + const link = useAccountLinkContext(); + // "reauth" only refreshes the browser SaaS session for attended reads — the + // sign-in already applied it to the Supabase client, so we just signal a + // refetch. It must NOT call completeLink (that re-registers → duplicate row). + const onLinked = + linkModalMode === "reauth" + ? () => markSaasSessionChanged() + : (session: SupabaseLoginSession) => link.completeLink(session); + return ( + + ); } /** @@ -87,22 +128,29 @@ export function App() { - - - - - - - - - - - - - - - - + + {/* TierProvider sits INSIDE LinkProvider so it can derive the tier + from the real link/subscription state when MSW mocks are off. */} + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/portal/src/ViewRouter.tsx b/frontend/portal/src/ViewRouter.tsx index 2efaabcc68..f56aa08bff 100644 --- a/frontend/portal/src/ViewRouter.tsx +++ b/frontend/portal/src/ViewRouter.tsx @@ -28,6 +28,8 @@ export function ViewRouter() { } /> } /> } /> + {/* Account-link is now a Settings panel; redirect legacy bookmarks home. */} + } /> {/* Settings is a modal overlay, not a route (see AppShell + UIContext). */} {/* Unknown paths land on Home. */} } /> diff --git a/frontend/portal/src/api/agents.ts b/frontend/portal/src/api/agents.ts index 842d5d398b..9956a3f66a 100644 --- a/frontend/portal/src/api/agents.ts +++ b/frontend/portal/src/api/agents.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { AgentsResponse } from "@portal/mocks/agents"; import type { Tier } from "@portal/contexts/TierContext"; @@ -16,7 +16,7 @@ export { AGENT_STATUS_TONE, TOOL_CATALOGUE } from "@portal/mocks/agents"; /** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */ export async function fetchAgents(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/agents?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/assistant.ts b/frontend/portal/src/api/assistant.ts index a6f10597f5..05e3bc2198 100644 --- a/frontend/portal/src/api/assistant.ts +++ b/frontend/portal/src/api/assistant.ts @@ -1,15 +1,18 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; /** GET /v1/assistant/suggestions */ export async function fetchAssistantSuggestions(): Promise { - return httpJson("/v1/assistant/suggestions"); + return apiClient.local.json("/v1/assistant/suggestions"); } /** POST /v1/assistant/messages */ export async function getAssistantReply(input: string): Promise { - const res = await httpJson<{ reply: string }>("/v1/assistant/messages", { - method: "POST", - body: { input }, - }); + const res = await apiClient.local.json<{ reply: string }>( + "/v1/assistant/messages", + { + method: "POST", + body: { input }, + }, + ); return res.reply; } diff --git a/frontend/portal/src/api/billing.ts b/frontend/portal/src/api/billing.ts new file mode 100644 index 0000000000..7c87bb5ca5 --- /dev/null +++ b/frontend/portal/src/api/billing.ts @@ -0,0 +1,79 @@ +import { apiClient } from "@portal/api/http"; +import type { Wallet } from "@shared/billing"; + +/** + * Real wallet + billing surface. All calls go to apiClient.saas — the hosted + * SaaS Java backend, authed by the admin's Supabase JWT. The wallet contract + * itself lives in {@code @shared/billing} (shared with the editor cloud surface). + */ + +// Re-export the shared contract so existing `@portal/api/billing` importers keep working. +export type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +} from "@shared/billing"; + +export async function fetchWallet(): Promise { + return apiClient.saas.json("/api/v1/payg/wallet"); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Cap — leader-only PATCH (real endpoint). +// ──────────────────────────────────────────────────────────────────────────── + +export async function updateCap(capUsd: number | null): Promise { + await apiClient.saas.json("/api/v1/payg/cap", { + method: "PATCH", + body: { capUsd: capUsd ?? 0, noCap: capUsd === null }, + }); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Invoices — backed by GET /api/v1/payg/invoices (reads stripe.invoices via +// the Sync Engine). Returns [] for free teams + missing schema. +// ──────────────────────────────────────────────────────────────────────────── + +export interface Invoice { + id: string; + number: string | null; + status: string; + totalMinor: number | null; + currency: string | null; + createdAt: string | null; + periodStart: string | null; + periodEnd: string | null; + hostedInvoiceUrl: string | null; + invoicePdf: string | null; + /** Product name from the subscription chain (e.g. "Stirling Processor Plan"). */ + description: string | null; + /** Billed units (PDFs) on this invoice; null when the line-item table isn't synced. */ + pdfsProcessed: number | null; +} + +export async function fetchInvoices(limit: number = 20): Promise { + return apiClient.saas.json( + `/api/v1/payg/invoices?limit=${encodeURIComponent(String(limit))}`, + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Payment method — GET /api/v1/payg/payment-method. Reads the default card off +// the Stripe mirror; `present: false` when the mirror doesn't carry one (table +// not synced / no card). Card edits happen in Stripe's portal, not here. +// ──────────────────────────────────────────────────────────────────────────── + +export interface PaymentMethod { + present: boolean; + brand: string | null; + last4: string | null; + expMonth: number | null; + expYear: number | null; +} + +export async function fetchPaymentMethod(): Promise { + return apiClient.saas.json("/api/v1/payg/payment-method"); +} diff --git a/frontend/portal/src/api/docs.ts b/frontend/portal/src/api/docs.ts index cc4a625016..0de1911d4f 100644 --- a/frontend/portal/src/api/docs.ts +++ b/frontend/portal/src/api/docs.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { Tier } from "@portal/contexts/TierContext"; import type { DocsContent, DocsNavSection } from "@portal/mocks/docs"; @@ -18,10 +18,10 @@ export type { /** GET /v1/docs/nav — the docs nav tree. */ export async function fetchDocsNav(): Promise { - return httpJson("/v1/docs/nav"); + return apiClient.local.json("/v1/docs/nav"); } /** GET /v1/docs/content — the tier-scaled reference content. */ export async function fetchDocsContent(tier: Tier): Promise { - return httpJson(`/v1/docs/content?tier=${tier}`); + return apiClient.local.json(`/v1/docs/content?tier=${tier}`); } diff --git a/frontend/portal/src/api/documents.ts b/frontend/portal/src/api/documents.ts index c64fd1227b..3e8e4e1170 100644 --- a/frontend/portal/src/api/documents.ts +++ b/frontend/portal/src/api/documents.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { DocumentsResponse } from "@portal/mocks/documents"; import type { Tier } from "@portal/contexts/TierContext"; @@ -20,7 +20,7 @@ export { /** GET /v1/documents?tier=… — summary strip + the review queue for the tier. */ export async function fetchDocuments(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/documents?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/editorDeploy.ts b/frontend/portal/src/api/editorDeploy.ts index f565cb2955..38517c0bc9 100644 --- a/frontend/portal/src/api/editorDeploy.ts +++ b/frontend/portal/src/api/editorDeploy.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { EditorDeploymentResponse } from "@portal/mocks/editorDeploy"; import type { Tier } from "@portal/contexts/TierContext"; @@ -29,7 +29,7 @@ export { export async function fetchEditorDeployment( tier: Tier, ): Promise { - return httpJson( + return apiClient.local.json( `/v1/editor/deployment?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/home.ts b/frontend/portal/src/api/home.ts index 122e1ca644..e7ddfea910 100644 --- a/frontend/portal/src/api/home.ts +++ b/frontend/portal/src/api/home.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { ActivityEvent, KpiEntry, @@ -23,25 +23,29 @@ export { PIPELINE_STAGES, PIPELINE_TEMPLATES } from "@portal/mocks/home"; /** GET /v1/analytics/usage?window=30d */ export async function fetchUsageSeries(): Promise { - return httpJson("/v1/analytics/usage?window=30d"); + return apiClient.local.json( + "/v1/analytics/usage?window=30d", + ); } /** GET /v1/activity?limit=8 */ export async function fetchRecentActivity(): Promise { - return httpJson("/v1/activity?limit=8"); + return apiClient.local.json("/v1/activity?limit=8"); } /** GET /v1/home/kpis?tier=… */ export async function fetchHomeKpis(tier: Tier): Promise { - return httpJson(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`); + return apiClient.local.json( + `/v1/home/kpis?tier=${encodeURIComponent(tier)}`, + ); } /** GET /v1/regions/health (Enterprise) */ export async function fetchRegionHealth(): Promise { - return httpJson("/v1/regions/health"); + return apiClient.local.json("/v1/regions/health"); } /** GET /v1/onboarding (Free) */ export async function fetchOnboarding(): Promise { - return httpJson("/v1/onboarding"); + return apiClient.local.json("/v1/onboarding"); } diff --git a/frontend/portal/src/api/http.test.ts b/frontend/portal/src/api/http.test.ts new file mode 100644 index 0000000000..085948c77f --- /dev/null +++ b/frontend/portal/src/api/http.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * api/http apiClient routing + error branches — the module exists specifically + * to make portal→backend routing explicit after /v1/billing/wallet once fell + * through to the local backend. The happy-path routing (saas hits the absolute + * base with the Supabase bearer) is covered in api/link.test.ts; here we pin the + * error/edge branches that gate the billing UI's error surface. + */ +const { getSession, getStoredTokenMock } = vi.hoisted(() => ({ + getSession: vi.fn(), + getStoredTokenMock: vi.fn(), +})); + +vi.mock("@shared/auth", () => ({ getStoredToken: getStoredTokenMock })); +vi.mock("@shared/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ auth: { getSession } }), + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +import { + apiClient, + HttpError, + SaasNotLinkedError, + SaasUnconfiguredError, +} from "@portal/api/http"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + getSession.mockReset(); + getStoredTokenMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function ok(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("apiClient.saas", () => { + it("throws SaasUnconfiguredError when VITE_SAAS_API_URL is unset", async () => { + vi.stubEnv("VITE_SAAS_API_URL", ""); + await expect( + apiClient.saas.json("/api/v1/payg/wallet"), + ).rejects.toBeInstanceOf(SaasUnconfiguredError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("throws SaasNotLinkedError when there is no SaaS session", async () => { + vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local"); + getSession.mockResolvedValue({ data: { session: null } }); + await expect( + apiClient.saas.json("/api/v1/payg/wallet"), + ).rejects.toBeInstanceOf(SaasNotLinkedError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("attaches the Supabase bearer and hits the absolute SaaS base", async () => { + vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local"); + getSession.mockResolvedValue({ + data: { session: { access_token: "supabase_tok" } }, + }); + fetchMock.mockResolvedValue(ok({ status: "free" })); + + const body = await apiClient.saas.json<{ status: string }>( + "/api/v1/payg/wallet", + ); + + expect(body.status).toBe("free"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://saas.test.local/api/v1/payg/wallet"); + expect((init.headers as Record).Authorization).toBe( + "Bearer supabase_tok", + ); + }); +}); + +describe("apiClient.local", () => { + it("attaches the Spring admin bearer and stays same-origin", async () => { + getStoredTokenMock.mockReturnValue("spring_tok"); + fetchMock.mockResolvedValue(ok({ linked: false })); + + await apiClient.local.json("/api/v1/account-link/status"); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/account-link/status"); + expect((init.headers as Record).Authorization).toBe( + "Bearer spring_tok", + ); + }); + + it("returns undefined for a 204 response", async () => { + getStoredTokenMock.mockReturnValue("spring_tok"); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + const result = await apiClient.local.json("/api/v1/account-link/unlink", { + method: "POST", + }); + + expect(result).toBeUndefined(); + }); + + it("throws HttpError with the status and parsed body on non-2xx", async () => { + getStoredTokenMock.mockReturnValue("spring_tok"); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "nope" }), { + status: 500, + statusText: "Internal Server Error", + headers: { "Content-Type": "application/json" }, + }), + ); + + const err = await apiClient.local + .json("/api/v1/account-link/status") + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(HttpError); + expect((err as HttpError).status).toBe(500); + expect((err as HttpError).body).toEqual({ error: "nope" }); + }); +}); diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts index 9e1619fe38..de8d89babc 100644 --- a/frontend/portal/src/api/http.ts +++ b/frontend/portal/src/api/http.ts @@ -1,16 +1,54 @@ /** - * Shared HTTP plumbing for the portal's service layer. + * Portal API client — explicit per-backend, per-credential routing. * - * Every `api/*.ts` module calls {@link httpJson}, which issues a real `fetch`. - * In dev and Storybook those requests are intercepted by the MSW handlers in - * `mocks/` and answered with fixture data; pointing at a real backend is just - * a matter of not registering MSW. Consumers don't change either way. + * ## Domains * - * The shared `stirling_jwt` bearer token (set by the auth gate, and shared - * same-origin with the editor) is attached automatically so portal data calls - * are authenticated once real backend endpoints exist. + * apiClient.local Same-origin (vite proxy → this instance's local + * Stirling backend on :8080). Spring admin bearer + * (`stirling_jwt` from @shared/auth) auto-attached. + * USE FOR: actions on this instance — + * /api/v1/account-link/{status,link,unlink}, etc. + * + * apiClient.saas VITE_SAAS_API_URL (hosted SaaS Java). The admin's + * Supabase JWT (from the account-link login, + * persisted + SDK-refreshed) is auto-attached. + * USE FOR: attended portal→SaaS reads — + * /api/v1/payg/wallet, etc. + * Throws SaasUnconfiguredError when VITE_SAAS_API_URL + * is missing — callers surface a clear "configure" + * state rather than silently routing to the wrong + * domain. + * + * Endpoints that don't have a real backend yet still target their eventual + * domain (almost always `.local`): with Mocks=on the MSW handlers intercept; + * with Mocks=off they hit the real backend and 404 until the route ships, then + * self-heal — no call-site migration needed. + * + * ## Why this is split, not a single function + * + * The two backends speak two credentials and resolve different identities. A + * single generic fetch reading the path prefix to pick a domain is implicit + + * fragile (the bug we hit: /v1/billing/wallet fell through to the local + * backend on a real run). Forcing the call site to say `.local` / `.saas` + * keeps the routing intent reviewable in diffs. + * + * ## Device credential isn't here + * + * The instance↔SaaS device credential ({@code X-Device-Id}+{@code X-Device-Secret}) + * is a server-side credential the local backend uses for UNATTENDED metering / + * entitlement calls. It never enters the portal — the browser is the human + * admin and uses the Supabase JWT for SaaS reads. Don't add it here. */ import { getStoredToken } from "@shared/auth"; +import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; + +/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */ +function saasBaseUrl(): string | null { + const raw = import.meta.env.VITE_SAAS_API_URL; + if (!raw) return null; + return raw.replace(/\/+$/, ""); +} export interface HttpRequestOptions { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -20,6 +58,7 @@ export interface HttpRequestOptions { signal?: AbortSignal; } +/** Thrown by any apiClient call on non-2xx response, with the parsed body. */ export class HttpError extends Error { constructor( public readonly status: number, @@ -31,6 +70,26 @@ export class HttpError extends Error { } } +/** Thrown by apiClient.saas.* when VITE_SAAS_API_URL isn't set. */ +export class SaasUnconfiguredError extends Error { + constructor() { + super( + "SaaS API not configured — set VITE_SAAS_API_URL to enable portal→SaaS reads.", + ); + this.name = "SaasUnconfiguredError"; + } +} + +/** Thrown by apiClient.saas.* when the admin has no SaaS session yet. */ +export class SaasNotLinkedError extends Error { + constructor() { + super( + "No SaaS session — admin must link an account before attended SaaS reads.", + ); + this.name = "SaasNotLinkedError"; + } +} + /** * Best-effort human-readable message from a thrown error: unwraps an * {@link HttpError}'s ProblemDetail-ish body (`detail` / `message` / `error`) @@ -49,16 +108,38 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function authHeader(): Record { +// ──────────────────────────────────────────────────────────────────────────── +// Shared response handler +// ──────────────────────────────────────────────────────────────────────────── + +async function unwrap(res: Response): Promise { + if (!res.ok) { + let body: unknown = null; + try { + body = await res.json(); + } catch { + // ignore — non-JSON error response + } + throw new HttpError(res.status, res.statusText, body); + } + // 204 / empty-body responses have nothing to parse. + if (res.status === 204 || res.headers.get("Content-Length") === "0") { + return undefined as T; + } + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +// ──────────────────────────────────────────────────────────────────────────── +// local — same-origin Stirling backend, Spring admin bearer +// ──────────────────────────────────────────────────────────────────────────── + +function localAuthHeader(): Record { const token = getStoredToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } -/** - * Thin JSON fetch wrapper used by every api module. In dev/Storybook the - * request is served by MSW; against a real backend it hits the network. - */ -export async function httpJson( +async function localJson( path: string, options: HttpRequestOptions = {}, ): Promise { @@ -69,20 +150,64 @@ export async function httpJson( ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), - ...authHeader(), + ...localAuthHeader(), ...options.headers, }, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, signal: options.signal, }); - if (!res.ok) { - let body: unknown = null; - try { - body = await res.json(); - } catch { - // ignore — non-JSON error response - } - throw new HttpError(res.status, res.statusText, body); - } - return (await res.json()) as T; + return unwrap(res); } + +// ──────────────────────────────────────────────────────────────────────────── +// saas — hosted SaaS Java, admin's Supabase JWT +// ──────────────────────────────────────────────────────────────────────────── + +async function getSaasAccessToken(): Promise { + ensureSaasSupabase(); + const supabase = getSupabaseClient(); + if (!supabase) return null; + const { data } = await supabase.auth.getSession(); + return data.session?.access_token ?? null; +} + +async function saasJson( + path: string, + options: HttpRequestOptions = {}, +): Promise { + const base = saasBaseUrl(); + if (!base) throw new SaasUnconfiguredError(); + const token = await getSaasAccessToken(); + if (!token) throw new SaasNotLinkedError(); + const res = await fetch(`${base}${path}`, { + method: options.method ?? "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...(options.body !== undefined + ? { "Content-Type": "application/json" } + : {}), + ...options.headers, + }, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + signal: options.signal, + }); + return unwrap(res); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Exported API client +// ──────────────────────────────────────────────────────────────────────────── + +export const apiClient = { + /** Local backend (this instance). Spring admin bearer auto-attached. */ + local: { + json: localJson, + }, + /** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */ + saas: { + json: saasJson, + /** True when VITE_SAAS_API_URL is set. Doesn't check session liveness. */ + isConfigured: (): boolean => Boolean(saasBaseUrl()), + }, +} as const; diff --git a/frontend/portal/src/api/infrastructure.ts b/frontend/portal/src/api/infrastructure.ts index 92255aece2..9d3023abd6 100644 --- a/frontend/portal/src/api/infrastructure.ts +++ b/frontend/portal/src/api/infrastructure.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { Tier } from "@portal/contexts/TierContext"; import type { ApiKey, @@ -57,32 +57,42 @@ const q = (tier: Tier) => `?tier=${encodeURIComponent(tier)}`; export async function fetchDeployments( tier: Tier, ): Promise { - return httpJson( + return apiClient.local.json( `/v1/infrastructure/deployments${q(tier)}`, ); } /** GET /v1/infrastructure/api-keys?tier=… */ export async function fetchApiKeys(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/api-keys${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/api-keys${q(tier)}`, + ); } /** GET /v1/infrastructure/security?tier=… */ export async function fetchSecurity(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/security${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/security${q(tier)}`, + ); } /** GET /v1/infrastructure/models?tier=… */ export async function fetchModels(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/models${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/models${q(tier)}`, + ); } /** GET /v1/infrastructure/storage?tier=… */ export async function fetchStorage(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/storage${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/storage${q(tier)}`, + ); } /** GET /v1/infrastructure/audit-log?tier=… */ export async function fetchAuditLog(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/audit-log${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/audit-log${q(tier)}`, + ); } diff --git a/frontend/portal/src/api/link.test.ts b/frontend/portal/src/api/link.test.ts new file mode 100644 index 0000000000..e6d9b12703 --- /dev/null +++ b/frontend/portal/src/api/link.test.ts @@ -0,0 +1,123 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { setupServer } from "msw/node"; +import { linkHandlers } from "@portal/mocks/handlers/link"; +import { resetLinkStore } from "@portal/mocks/link"; + +// Mock the shared Supabase client used by apiClient.saas. The team-wide +// /instances + /instances/:id/revoke calls go to SaaS now (auto-attached +// Bearer = current Supabase access token). Hoisted so vi.mock can see it. +const { getSession } = vi.hoisted(() => ({ + getSession: vi.fn().mockResolvedValue({ + data: { session: { access_token: "supabase_jwt_test" } }, + }), +})); +vi.mock("@shared/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ auth: { getSession } }), + configureSupabase: vi.fn(), +})); + +// Pretend the SaaS base URL is configured so apiClient.saas calls don't throw +// SaasUnconfiguredError. MSW's wildcard handlers (`*/...`) intercept the +// absolute URL the same way they do the relative one. +vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local"); + +import { + fetchInstances, + fetchStatus, + linkInstance, + revokeInstance, + unlinkInstance, +} from "@portal/api/link"; + +const server = setupServer(...linkHandlers); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => { + server.close(); + vi.unstubAllEnvs(); +}); +beforeEach(() => resetLinkStore()); + +describe("api/link — local backend (this instance)", () => { + it("starts not-linked", async () => { + const status = await fetchStatus(); + expect(status.linked).toBe(false); + }); + + it("links this instance via the local endpoint, never returning a secret", async () => { + const status = await linkInstance({ + supabaseJwt: "jwt_abc", + name: "node-1", + }); + expect(status.linked).toBe(true); + expect(status.name).toBe("node-1"); + // Contract: the device secret is stored server-side, never sent to the portal. + expect(status).not.toHaveProperty("deviceSecret"); + expect(status).not.toHaveProperty("deviceId"); + expect(await (await fetchStatus()).linked).toBe(true); + }); + + it("unlinks this instance", async () => { + await linkInstance({ supabaseJwt: "jwt_abc" }); + // unlink returns 204 (no body); the status is read back separately. + await unlinkInstance(); + expect((await fetchStatus()).linked).toBe(false); + }); + + it("forwards the SaaS JWT in the link body", async () => { + let seenBody: unknown = null; + server.events.on("request:start", async ({ request }) => { + if (request.method === "POST" && request.url.endsWith("/link")) { + seenBody = await request.clone().json(); + } + }); + await linkInstance({ supabaseJwt: "jwt_xyz", name: "n" }); + expect(seenBody).toMatchObject({ supabaseJwt: "jwt_xyz" }); + server.events.removeAllListeners(); + }); +}); + +describe("api/link — SaaS backend (team-wide)", () => { + it("fetches the instance list", async () => { + const rows = await fetchInstances(); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]).toHaveProperty("deviceId"); + }); + + it("revokes an instance", async () => { + const active = (await fetchInstances()).find((r) => !r.revoked)!; + await revokeInstance(active.instanceId); + const after = await fetchInstances(); + expect(after.find((r) => r.instanceId === active.instanceId)?.revoked).toBe( + true, + ); + }); + + it("hits the absolute SaaS URL with the Supabase JWT as Bearer", async () => { + let seenUrl: string | null = null; + let seenAuth: string | null = null; + const capture = ({ request }: { request: Request }) => { + if (request.url.includes("/account-link/instances")) { + seenUrl = request.url; + seenAuth = request.headers.get("authorization"); + } + }; + server.events.on("request:start", capture); + await fetchInstances(); + expect(seenUrl).toBe( + "https://saas.test.local/api/v1/account-link/instances", + ); + expect(seenAuth).toBe("Bearer supabase_jwt_test"); + server.events.removeAllListeners(); + }); +}); diff --git a/frontend/portal/src/api/link.ts b/frontend/portal/src/api/link.ts new file mode 100644 index 0000000000..c55978dffc --- /dev/null +++ b/frontend/portal/src/api/link.ts @@ -0,0 +1,85 @@ +import { apiClient } from "@portal/api/http"; +import type { + LinkInstanceRequest, + LinkStatus, + LinkedInstanceRow, +} from "@portal/mocks/link"; + +export type { + LinkInstanceRequest, + LinkStatus, + LinkedInstanceRow, +} from "@portal/mocks/link"; + +/** + * Account-link client (combined-billing "Mode A"). Two distinct surfaces: + * + * THIS instance — apiClient.local (Spring admin bearer auto-attached): + * - POST /api/v1/account-link/link — hand the local backend the admin's + * SaaS JWT in the body. It registers + * with SaaS + stores the device + * secret SERVER-SIDE; the portal + * NEVER receives or renders it. + * - GET /api/v1/account-link/status — Linked / Not-linked for this + * instance. + * - POST /api/v1/account-link/unlink — drop this instance's link (local + * backend best-effort tells SaaS). + * + * TEAM-WIDE management — apiClient.saas (admin's Supabase JWT auto-attached + * from the in-app account-link login): + * - GET /api/v1/account-link/instances — every linked instance + * - POST /api/v1/account-link/instances/{id}/revoke + * + * The team-wide endpoints are served by the hosted SaaS Java backend (the + * local backend has no such routes), so they go through apiClient.saas. They're + * MSW-intercepted in dev/Storybook via wildcard handlers that match both the + * local and absolute SaaS URLs. + */ + +const BASE = "/api/v1/account-link"; + +/** + * Link THIS instance. The local backend takes the SaaS JWT, registers with + * SaaS, and persists the device secret itself; the response carries only the + * resulting link status. No secret is returned. + */ +export async function linkInstance( + req: LinkInstanceRequest, +): Promise { + return apiClient.local.json(`${BASE}/link`, { + method: "POST", + body: req, + }); +} + +/** Linked / Not-linked for this instance. */ +export async function fetchStatus(): Promise { + return apiClient.local.json(`${BASE}/status`); +} + +/** + * Drop this instance's link. The local backend best-effort tells SaaS to + * revoke before clearing the credential locally, then returns 204 — there's no + * body, so the caller sets the known unlinked status itself. + */ +export async function unlinkInstance(): Promise { + await apiClient.local.json(`${BASE}/unlink`, { method: "POST" }); +} + +/** + * Every linked instance for the team — SaaS-direct call with the admin's + * Supabase JWT (no longer takes an accessToken parameter; the saas client + * resolves the live session itself). + */ +export async function fetchInstances(): Promise { + return apiClient.saas.json(`${BASE}/instances`); +} + +/** + * Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT. + */ +export async function revokeInstance(instanceId: number): Promise { + await apiClient.saas.json(`${BASE}/instances/${instanceId}/revoke`, { + method: "POST", + }); +} diff --git a/frontend/portal/src/api/notifications.ts b/frontend/portal/src/api/notifications.ts index 21b6e33f66..ac0b2f85bc 100644 --- a/frontend/portal/src/api/notifications.ts +++ b/frontend/portal/src/api/notifications.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { Notification, NotificationCategory, @@ -8,12 +8,12 @@ export type { Notification, NotificationCategory }; /** GET /v1/notifications */ export async function fetchNotifications(): Promise { - return httpJson("/v1/notifications"); + return apiClient.local.json("/v1/notifications"); } /** POST /v1/notifications/mark-all-read */ export async function markAllNotificationsRead(): Promise { - await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", { + await apiClient.local.json<{ ok: true }>("/v1/notifications/mark-all-read", { method: "POST", }); } diff --git a/frontend/portal/src/api/ops.ts b/frontend/portal/src/api/ops.ts index e741793bf6..c8a564f307 100644 --- a/frontend/portal/src/api/ops.ts +++ b/frontend/portal/src/api/ops.ts @@ -1,11 +1,11 @@ -import { HttpError, httpJson } from "@portal/api/http"; +import { apiClient, HttpError } from "@portal/api/http"; import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops"; export type { FeaturedOp, OpResultMap }; /** GET /v1/ops/featured */ export async function fetchFeaturedOps(): Promise { - return httpJson("/v1/ops/featured"); + return apiClient.local.json("/v1/ops/featured"); } export class UnknownOpError extends Error { @@ -21,13 +21,13 @@ export async function runSingleOp( sample: string, ): Promise<{ result: OpResultMap; durationMs: number }> { try { - return await httpJson<{ result: OpResultMap; durationMs: number }>( - `/v1/ops/${encodeURIComponent(opId)}/run`, - { - method: "POST", - body: { sample }, - }, - ); + return await apiClient.local.json<{ + result: OpResultMap; + durationMs: number; + }>(`/v1/ops/${encodeURIComponent(opId)}/run`, { + method: "POST", + body: { sample }, + }); } catch (err) { if (err instanceof HttpError && err.status === 404) { throw new UnknownOpError(opId); diff --git a/frontend/portal/src/api/pipelines.ts b/frontend/portal/src/api/pipelines.ts index e9c3ae2f2e..e7bb6222b6 100644 --- a/frontend/portal/src/api/pipelines.ts +++ b/frontend/portal/src/api/pipelines.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { PipelinesResponse } from "@portal/mocks/pipelines"; import type { Tier } from "@portal/contexts/TierContext"; @@ -18,7 +18,7 @@ export type { /** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */ export async function fetchPipelines(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/pipelines?tier=${encodeURIComponent(tier)}`, ); } @@ -32,7 +32,7 @@ export async function fetchPipelines(tier: Tier): Promise { * handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted. */ export async function promoteToPolicy(id: string): Promise<{ ok: true }> { - return httpJson<{ ok: true }>( + return apiClient.local.json<{ ok: true }>( `/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`, { method: "POST" }, ); diff --git a/frontend/portal/src/api/policies.ts b/frontend/portal/src/api/policies.ts index c17a5f0b11..fba5eda1eb 100644 --- a/frontend/portal/src/api/policies.ts +++ b/frontend/portal/src/api/policies.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { PoliciesResponse, Policy } from "@portal/mocks/policies"; /** @@ -45,12 +45,14 @@ export { /** GET /api/v1/policies — the catalogue + every configured policy. */ export async function fetchPolicies(): Promise { - return httpJson("/api/v1/policies"); + return apiClient.local.json("/api/v1/policies"); } /** GET /api/v1/policies/{id} — one stored policy's raw record. */ export async function fetchPolicy(id: string): Promise { - return httpJson(`/api/v1/policies/${encodeURIComponent(id)}`); + return apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}`, + ); } /** @@ -58,14 +60,20 @@ export async function fetchPolicy(id: string): Promise { * assigns owner + team server-side and returns the stored policy with its id. */ export async function savePolicy(policy: Policy): Promise { - return httpJson("/api/v1/policies", { method: "POST", body: policy }); + return apiClient.local.json("/api/v1/policies", { + method: "POST", + body: policy, + }); } /** DELETE /api/v1/policies/{id} — remove a stored policy. */ export async function deletePolicy(id: string): Promise { - await httpJson(`/api/v1/policies/${encodeURIComponent(id)}`, { - method: "DELETE", - }); + await apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); } /** The async run acknowledgement: a run id to poll for status. */ @@ -83,7 +91,7 @@ export interface PolicyRunResponse { * run id. Runs regardless of the policy's enabled flag. */ export async function runPolicy(id: string): Promise { - return httpJson( + return apiClient.local.json( `/api/v1/policies/${encodeURIComponent(id)}/run`, { method: "POST" }, ); diff --git a/frontend/portal/src/api/sdkComponents.ts b/frontend/portal/src/api/sdkComponents.ts index ed02afd9a1..3a43f158a3 100644 --- a/frontend/portal/src/api/sdkComponents.ts +++ b/frontend/portal/src/api/sdkComponents.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { ComponentsResponse } from "@portal/mocks/sdkComponents"; import type { Tier } from "@portal/contexts/TierContext"; @@ -22,7 +22,7 @@ export { /** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */ export async function fetchComponents(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/components?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/search.ts b/frontend/portal/src/api/search.ts index 6a7b600df8..2f085066c9 100644 --- a/frontend/portal/src/api/search.ts +++ b/frontend/portal/src/api/search.ts @@ -1,9 +1,9 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { QuickAction } from "@portal/mocks/search"; export type { QuickAction }; /** GET /v1/search/quick-actions */ export async function fetchQuickActions(): Promise { - return httpJson("/v1/search/quick-actions"); + return apiClient.local.json("/v1/search/quick-actions"); } diff --git a/frontend/portal/src/api/settings.ts b/frontend/portal/src/api/settings.ts index 8cba5d0cc1..1ec1cb45b3 100644 --- a/frontend/portal/src/api/settings.ts +++ b/frontend/portal/src/api/settings.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { SettingsSnapshot } from "@portal/mocks/settings"; import type { Tier } from "@portal/contexts/TierContext"; @@ -13,7 +13,7 @@ export type { /** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */ export async function fetchSettings(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/settings?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/sources.ts b/frontend/portal/src/api/sources.ts index 29988958b5..9f9839d7a1 100644 --- a/frontend/portal/src/api/sources.ts +++ b/frontend/portal/src/api/sources.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; /** * Sources service layer: the backend contract. @@ -59,22 +59,30 @@ export interface Source { /** GET /api/v1/sources: KPI strip + one row per source for the admin. */ export async function fetchSources(): Promise { - return httpJson("/api/v1/sources"); + return apiClient.local.json("/api/v1/sources"); } /** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */ export async function fetchSource(id: string): Promise { - return httpJson(`/api/v1/sources/${encodeURIComponent(id)}`); + return apiClient.local.json( + `/api/v1/sources/${encodeURIComponent(id)}`, + ); } /** POST /api/v1/sources: create (blank id) or update (matched id) a source. */ export async function createSource(source: Source): Promise { - return httpJson("/api/v1/sources", { method: "POST", body: source }); + return apiClient.local.json("/api/v1/sources", { + method: "POST", + body: source, + }); } /** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */ export async function deleteSource(id: string): Promise { - await httpJson(`/api/v1/sources/${encodeURIComponent(id)}`, { - method: "DELETE", - }); + await apiClient.local.json( + `/api/v1/sources/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); } diff --git a/frontend/portal/src/api/usage.ts b/frontend/portal/src/api/usage.ts deleted file mode 100644 index 6e80d8312c..0000000000 --- a/frontend/portal/src/api/usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { httpJson } from "@portal/api/http"; -import type { Tier } from "@portal/contexts/TierContext"; -import type { - BillingHistoryRow, - BillingSummary, - PlanOption, - UsageSeriesResponse, -} from "@portal/mocks/usage"; - -export type { - BillingHistoryRow, - BillingSummary, - InvoiceStatus, - PlanOption, - UsagePoint, - UsageSeriesResponse, -} from "@portal/mocks/usage"; -export { OVERAGE_RATE } from "@portal/mocks/usage"; - -/** GET /v1/billing/usage — 30-day docs-processed series. */ -export async function fetchBillingUsage(): Promise { - return httpJson("/v1/billing/usage"); -} - -/** GET /v1/billing/summary?tier=… — KPI strip + current-plan figures. */ -export async function fetchBillingSummary(tier: Tier): Promise { - return httpJson( - `/v1/billing/summary?tier=${encodeURIComponent(tier)}`, - ); -} - -/** GET /v1/billing/plans — available plan catalogue. */ -export async function fetchPlanOptions(): Promise { - return httpJson("/v1/billing/plans"); -} - -/** GET /v1/billing/history?tier=… — invoice / line-item history. */ -export async function fetchBillingHistory( - tier: Tier, -): Promise { - return httpJson( - `/v1/billing/history?tier=${encodeURIComponent(tier)}`, - ); -} diff --git a/frontend/portal/src/api/users.ts b/frontend/portal/src/api/users.ts index f9e7b11c08..4c0fa5dcb6 100644 --- a/frontend/portal/src/api/users.ts +++ b/frontend/portal/src/api/users.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { UsersResponse } from "@portal/mocks/users"; import type { Tier } from "@portal/contexts/TierContext"; @@ -20,5 +20,7 @@ export { /** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */ export async function fetchUsers(tier: Tier): Promise { - return httpJson(`/v1/users?tier=${encodeURIComponent(tier)}`); + return apiClient.local.json( + `/v1/users?tier=${encodeURIComponent(tier)}`, + ); } diff --git a/frontend/portal/src/auth/saasSupabase.ts b/frontend/portal/src/auth/saasSupabase.ts new file mode 100644 index 0000000000..25a8a6ed6b --- /dev/null +++ b/frontend/portal/src/auth/saasSupabase.ts @@ -0,0 +1,42 @@ +import { + configureSupabase, + getSupabaseClient, +} from "@shared/auth/supabase/supabaseClient"; + +/** + * Configures the shared Supabase client against the hosted SaaS project so the + * portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a + * separate, transient SaaS auth — the portal's own session stays Spring (the + * local instance admin); calls to the local backend still carry the Spring + * bearer, and the SaaS JWT is passed only in the link request body. + * + * Config: VITE_SAAS_SUPABASE_URL + VITE_SAAS_SUPABASE_ANON_KEY (both public). + * Absent → {@link isSaasSupabaseConfigured} is false and the link UI degrades to + * a "configure the SaaS Supabase URL" state. + */ +const url = import.meta.env.VITE_SAAS_SUPABASE_URL; +const key = import.meta.env.VITE_SAAS_SUPABASE_ANON_KEY; + +export const isSaasSupabaseConfigured = Boolean(url && key); + +/** OAuth providers the hosted SaaS login offers (mirrors the SaaS editor login). */ +export const SAAS_OAUTH_PROVIDERS = ["google", "github", "apple", "azure"]; + +/** sessionStorage marker set before an SSO redirect so the return can finish the link. */ +export const PENDING_LINK_KEY = "stirling-account-link-pending"; + +let configured = false; + +/** + * Configure the shared Supabase client once (idempotent). Returns the client, or + * null when the SaaS Supabase env isn't set. `detectSessionInUrl` (on by default) + * means an SSO redirect back to the portal is picked up here. + */ +export function ensureSaasSupabase() { + if (!isSaasSupabaseConfigured) return null; + if (!configured) { + configureSupabase({ url: url as string, key: key as string }); + configured = true; + } + return getSupabaseClient(); +} diff --git a/frontend/portal/src/auth/saasSupabaseLogin.test.ts b/frontend/portal/src/auth/saasSupabaseLogin.test.ts new file mode 100644 index 0000000000..5517897ec9 --- /dev/null +++ b/frontend/portal/src/auth/saasSupabaseLogin.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +// Mock the shared Supabase client the in-app link login is wired to. Declared +// via vi.hoisted so the vi.mock factory can reference them. +const { signInWithPassword, signInWithOAuth } = vi.hoisted(() => ({ + signInWithPassword: vi.fn(), + signInWithOAuth: vi.fn(), +})); + +vi.mock("@shared/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ auth: { signInWithPassword, signInWithOAuth } }), +})); + +import { useSupabaseLogin } from "@shared/auth/ui/useSupabaseLogin"; + +describe("useSupabaseLogin (in-app account-link login)", () => { + beforeEach(() => { + signInWithPassword.mockReset(); + signInWithOAuth.mockReset(); + }); + + it("fires onSuccess with the access token on a successful email sign-in", async () => { + signInWithPassword.mockResolvedValue({ + data: { session: { access_token: "tok-123" } }, + error: null, + }); + const onSuccess = vi.fn(); + const { result } = renderHook(() => useSupabaseLogin({ onSuccess })); + + act(() => { + result.current.setEmail("admin@org.com"); + result.current.setPassword("pw"); + }); + await act(async () => { + await result.current.signInWithEmail(); + }); + + expect(signInWithPassword).toHaveBeenCalledWith({ + email: "admin@org.com", + password: "pw", + }); + expect(onSuccess).toHaveBeenCalledWith({ access_token: "tok-123" }); + expect(result.current.error).toBeNull(); + }); + + it("surfaces an email sign-in error and skips onSuccess", async () => { + signInWithPassword.mockResolvedValue({ + data: { session: null }, + error: { message: "Invalid login credentials" }, + }); + const onSuccess = vi.fn(); + const { result } = renderHook(() => useSupabaseLogin({ onSuccess })); + + act(() => { + result.current.setEmail("admin@org.com"); + result.current.setPassword("nope"); + }); + await act(async () => { + await result.current.signInWithEmail(); + }); + + expect(result.current.error).toBe("Invalid login credentials"); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it("kicks off OAuth with the provider, redirect, and pre-redirect hook", async () => { + signInWithOAuth.mockResolvedValue({ data: {}, error: null }); + const onBeforeOAuth = vi.fn(); + const { result } = renderHook(() => + useSupabaseLogin({ + providers: ["google", "github"], + redirectTo: "http://portal.local/account-link", + onBeforeOAuth, + }), + ); + + expect(result.current.hasProviders).toBe(true); + await act(async () => { + await result.current.signInWithProvider("google"); + }); + + expect(onBeforeOAuth).toHaveBeenCalledWith("google"); + expect(signInWithOAuth).toHaveBeenCalledWith({ + provider: "google", + options: { redirectTo: "http://portal.local/account-link" }, + }); + }); + + it("validates both fields before calling Supabase", async () => { + const { result } = renderHook(() => useSupabaseLogin()); + await act(async () => { + await result.current.signInWithEmail(); + }); + expect(signInWithPassword).not.toHaveBeenCalled(); + expect(result.current.error).toBeTruthy(); + }); +}); diff --git a/frontend/portal/src/billing/sharedBillingFormat.test.ts b/frontend/portal/src/billing/sharedBillingFormat.test.ts new file mode 100644 index 0000000000..8882df796b --- /dev/null +++ b/frontend/portal/src/billing/sharedBillingFormat.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + currencySymbol, + docCapForMoney, + formatMinor, + formatPeriodDate, + meterState, +} from "@shared/billing"; + +/** + * Unit tests for the @shared/billing money/meter helpers the portal billing + * surface (and the editor cloud surface) depend on. docCapForMoney mirrors the + * backend's cap→PDF conversion and meterState mirrors the BE warn/degrade bands, + * so these invariants matter beyond cosmetics. + */ +describe("docCapForMoney", () => { + it("returns null when there is no cap", () => { + expect(docCapForMoney(null, 2)).toBeNull(); + }); + + it("returns null when the rate is unresolved or non-positive", () => { + expect(docCapForMoney(1000, null)).toBeNull(); + expect(docCapForMoney(1000, 0)).toBeNull(); + expect(docCapForMoney(1000, -5)).toBeNull(); + }); + + it("floors capMinor / rate (the backend mirror)", () => { + // $1000 cap, 2 minor units / doc → floor(100000 / 2) = 50000 PDFs. + expect(docCapForMoney(1000, 2)).toBe(50000); + // Sub-cent rate (0.5 minor) → floor(100000 / 0.5) = 200000. + expect(docCapForMoney(1000, 0.5)).toBe(200000); + // Floors a partial PDF down. + expect(docCapForMoney(10, 3)).toBe(333); + }); + + it("treats a $0 cap as zero paid PDFs", () => { + expect(docCapForMoney(0, 2)).toBe(0); + }); +}); + +describe("meterState", () => { + it("is FULL below the warn band", () => { + expect(meterState(10, 100).state).toBe("FULL"); + expect(meterState(79, 100).state).toBe("FULL"); + }); + + it("is WARNED from 80% up to (not including) 100%", () => { + expect(meterState(80, 100).state).toBe("WARNED"); + expect(meterState(99, 100).state).toBe("WARNED"); + }); + + it("is DEGRADED at and above 100%, with pct clamped to 100", () => { + expect(meterState(100, 100)).toEqual({ state: "DEGRADED", pct: 100 }); + const over = meterState(500, 100); + expect(over.state).toBe("DEGRADED"); + expect(over.pct).toBe(100); + }); + + it("treats a non-positive limit as fully consumed", () => { + expect(meterState(0, 0)).toEqual({ state: "DEGRADED", pct: 100 }); + }); +}); + +describe("currencySymbol", () => { + it("maps known currencies and defaults empty/usd to $", () => { + expect(currencySymbol("usd")).toBe("$"); + expect(currencySymbol("")).toBe("$"); + expect(currencySymbol(null)).toBe("$"); + expect(currencySymbol("eur")).toBe("€"); + expect(currencySymbol("gbp")).toBe("£"); + }); + + it("falls back to the upper-cased code for anything unmapped", () => { + expect(currencySymbol("cad")).toBe("CAD "); + }); +}); + +describe("formatMinor", () => { + it("formats whole and fractional cents", () => { + expect(formatMinor(224, "usd")).toContain("2.24"); + expect(formatMinor(5, "usd")).toContain("0.05"); + }); + + it("keeps up to 3 fraction digits so sub-cent rates don't round to $0", () => { + expect(formatMinor(0.5, "usd")).toContain("0.005"); + }); +}); + +describe("formatPeriodDate", () => { + it("returns an empty string for null", () => { + expect(formatPeriodDate(null)).toBe(""); + }); + + it("formats the date part of an ISO string", () => { + const out = formatPeriodDate("2026-06-24"); + expect(out).toContain("Jun"); + expect(out).toContain("24"); + }); + + it("includes the year only when asked", () => { + expect(formatPeriodDate("2026-06-24")).not.toContain("2026"); + expect(formatPeriodDate("2026-06-24", { year: true })).toContain("2026"); + }); +}); diff --git a/frontend/portal/src/billing/stripe.test.ts b/frontend/portal/src/billing/stripe.test.ts new file mode 100644 index 0000000000..eeb9e16dcd --- /dev/null +++ b/frontend/portal/src/billing/stripe.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Branch coverage for the Stripe edge-function client: the embedded-checkout vs + * already-subscribed-redirect vs neither-secret-nor-url mapping, the mock flag, + * unconfigured Supabase, and the portal-session path. + */ +const { getClient, invoke } = vi.hoisted(() => ({ + getClient: vi.fn(), + invoke: vi.fn(), +})); + +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); +vi.mock("@shared/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => getClient(), + configureSupabase: vi.fn(), +})); + +import { + createCheckoutSession, + createPortalSession, + StripeFunctionError, +} from "@portal/billing/stripe"; + +const req = { teamId: 1, successUrl: "s", cancelUrl: "c" } as const; + +beforeEach(() => { + invoke.mockReset(); + getClient.mockReset().mockReturnValue({ functions: { invoke } }); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("createCheckoutSession", () => { + it("maps embedded checkout (client_secret)", async () => { + invoke.mockResolvedValue({ + data: { success: true, client_secret: "cs_123" }, + error: null, + }); + const s = await createCheckoutSession(req); + expect(s).toEqual({ + clientSecret: "cs_123", + redirectUrl: null, + alreadySubscribed: false, + mock: false, + }); + }); + + it("short-circuits already-subscribed to the portal URL (no client secret)", async () => { + invoke.mockResolvedValue({ + data: { + success: true, + already_subscribed: true, + portal_url: "https://portal", + }, + error: null, + }); + const s = await createCheckoutSession(req); + expect(s.alreadySubscribed).toBe(true); + expect(s.redirectUrl).toBe("https://portal"); + expect(s.clientSecret).toBeNull(); + }); + + it("flags a mock client secret", async () => { + invoke.mockResolvedValue({ + data: { success: true, client_secret: "cs_mock_abc" }, + error: null, + }); + expect((await createCheckoutSession(req)).mock).toBe(true); + }); + + it("throws when success is false", async () => { + invoke.mockResolvedValue({ + data: { success: false, error: "no team" }, + error: null, + }); + await expect(createCheckoutSession(req)).rejects.toBeInstanceOf( + StripeFunctionError, + ); + }); + + it("throws when neither client_secret nor url is returned", async () => { + invoke.mockResolvedValue({ data: { success: true }, error: null }); + await expect(createCheckoutSession(req)).rejects.toThrow(/neither/); + }); + + it("throws unconfigured when there is no Supabase client", async () => { + getClient.mockReturnValue(null); + const err = await createCheckoutSession(req).catch((e: unknown) => e); + expect(err).toBeInstanceOf(StripeFunctionError); + expect((err as StripeFunctionError).code).toBe("unconfigured"); + }); +}); + +describe("createPortalSession", () => { + it("returns the portal URL", async () => { + invoke.mockResolvedValue({ + data: { success: true, url: "https://billing" }, + error: null, + }); + expect(await createPortalSession({ teamId: 1, returnUrl: "r" })).toBe( + "https://billing", + ); + }); + + it("throws on a free team (no url)", async () => { + invoke.mockResolvedValue({ + data: { success: false, error: "team_not_subscribed" }, + error: null, + }); + await expect( + createPortalSession({ teamId: 1, returnUrl: "r" }), + ).rejects.toBeInstanceOf(StripeFunctionError); + }); +}); diff --git a/frontend/portal/src/billing/stripe.ts b/frontend/portal/src/billing/stripe.ts new file mode 100644 index 0000000000..068046ef37 --- /dev/null +++ b/frontend/portal/src/billing/stripe.ts @@ -0,0 +1,170 @@ +import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; + +/** + * Stripe checkout + portal sessions, minted via the SaaS Supabase edge + * functions (no new Java endpoints). Same pattern the SaaS web app uses for + * its Plan page — `supabase.functions.invoke` carries the admin's JWT + * automatically, and the edge functions resolve the team via the + * `payg_get_checkout_context` RPC. + */ + +export class StripeFunctionError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = "StripeFunctionError"; + } +} + +/** Currencies the SaaS PAYG offering supports. Default for new checkouts is "usd". */ +export type SaasCurrency = "usd" | "eur" | "gbp"; + +interface CheckoutSessionRequest { + teamId: number; + /** Where Stripe redirects on success — typically the portal billing page. */ + successUrl: string; + /** Where Stripe redirects on cancel/close. */ + cancelUrl: string; + /** ISO 4217 lower-case. Defaults to "usd"; portal uses the wallet's currency when set. */ + currency?: SaasCurrency; + /** Optional prefill for the Stripe Checkout email field. */ + billingOwnerEmail?: string; +} + +interface PortalSessionRequest { + teamId: number; + returnUrl: string; +} + +/** + * Checkout response shape. The edge function defaults to embedded Stripe + * Checkout (returns {@code client_secret}); it can also return: + * - {@code portal_url} + {@code already_subscribed: true} when the team is + * already on PAYG (short-circuit so the click still does something useful) + * - {@code url} alongside or instead of {@code client_secret} for hosted / + * redirect-mode flows (rare; embedded is the default for the SaaS UX). + */ +interface CheckoutResponse { + success: boolean; + client_secret?: string; + url?: string; + portal_url?: string; + already_subscribed?: boolean; + mock?: boolean; + error?: string; +} + +interface PortalResponse { + success: boolean; + url?: string; + error?: string; +} + +async function invoke( + name: string, + body: Record, +): Promise { + ensureSaasSupabase(); + const supabase = getSupabaseClient(); + if (!supabase) { + throw new StripeFunctionError( + "SaaS Supabase not configured — set VITE_SAAS_SUPABASE_URL.", + "unconfigured", + ); + } + const { data, error } = await supabase.functions.invoke(name, { body }); + if (error) { + throw new StripeFunctionError( + error.message ?? `Edge function ${name} failed`, + ); + } + if (data == null) { + throw new StripeFunctionError(`Edge function ${name} returned no data`); + } + return data; +} + +/** + * Result of {@link createCheckoutSession}. Exactly ONE of {@code clientSecret} + * or {@code redirectUrl} is set: clientSecret drives embedded Stripe Checkout + * (the default UX, matching the SaaS web app); redirectUrl is used for the + * already-subscribed short-circuit (portal URL) or any hosted-mode fallback. + */ +export interface CheckoutSession { + clientSecret: string | null; + redirectUrl: string | null; + alreadySubscribed: boolean; + mock: boolean; +} + +/** + * Mint a Stripe Checkout session for PAYG subscription. Defaults to embedded + * Checkout (returns {@code clientSecret}) so the portal can mount + * <EmbeddedCheckoutProvider> inline. If the team is already subscribed the + * edge function short-circuits to a Customer Portal URL — surfaced as + * {@code redirectUrl} + {@code alreadySubscribed=true} so the caller can open it + * in a new tab instead of trying to mount a checkout iframe with no secret. + */ +export async function createCheckoutSession( + req: CheckoutSessionRequest, +): Promise { + const res = await invoke("create-checkout-session", { + team_id: req.teamId, + currency: req.currency ?? "usd", + success_url: req.successUrl, + cancel_url: req.cancelUrl, + ...(req.billingOwnerEmail + ? { billing_owner_email: req.billingOwnerEmail } + : {}), + }); + if (!res.success) { + throw new StripeFunctionError( + res.error ?? "create-checkout-session failed", + ); + } + const alreadySubscribed = Boolean(res.already_subscribed); + const redirectUrl = alreadySubscribed + ? (res.portal_url ?? null) + : (res.url ?? null); + const clientSecret = alreadySubscribed ? null : (res.client_secret ?? null); + if (!clientSecret && !redirectUrl) { + throw new StripeFunctionError( + "create-checkout-session returned neither client_secret nor URL", + ); + } + return { + clientSecret, + redirectUrl, + alreadySubscribed, + mock: Boolean(res.mock) || clientSecret?.startsWith("cs_mock_") === true, + }; +} + +/** {@code VITE_STRIPE_PUBLISHABLE_KEY} — the Stripe pk used by embedded Checkout. */ +export function getStripePublishableKey(): string { + return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; +} + +/** + * Mint a Stripe Customer Portal session. The admin can manage their card, + * view invoices, and cancel from Stripe's hosted UI. The edge function returns + * 404 with {@code team_not_subscribed} if called for a free team — surfaced + * here as a StripeFunctionError the caller can toast. + */ +export async function createPortalSession( + req: PortalSessionRequest, +): Promise { + const res = await invoke("create-customer-portal-session", { + team_id: req.teamId, + return_url: req.returnUrl, + }); + if (!res.success || !res.url) { + throw new StripeFunctionError( + res.error ?? "create-customer-portal-session failed", + ); + } + return res.url; +} diff --git a/frontend/portal/src/components/AppShell.css b/frontend/portal/src/components/AppShell.css index 1c7491d9a5..03702966e4 100644 --- a/frontend/portal/src/components/AppShell.css +++ b/frontend/portal/src/components/AppShell.css @@ -1,6 +1,14 @@ +/* Fixed-height shell so the MAIN COLUMN scrolls, not the document. With + min-height:100vh the shell grows with content and the document scrolls, which + means .portal-shell__view's overflow-y never engages and any `position:sticky` + inside it (e.g. a page header) rides away with the document. Pinning the shell + to the viewport (+ min-height:0 on the flex descendants so they can shrink + below content) makes .portal-shell__view the scroll container, so sticky + page headers stick under the global header. */ .portal-shell { display: flex; - min-height: 100vh; + height: 100vh; + overflow: hidden; background: var(--color-bg); color: var(--color-text-2); } @@ -10,10 +18,12 @@ display: flex; flex-direction: column; min-width: 0; /* prevent grid blowout on narrow content */ + min-height: 0; /* allow the column to bound its children */ } .portal-shell__view { flex: 1 1 auto; + min-height: 0; /* scroll instead of growing past the viewport */ overflow-y: auto; animation: fadeInUp var(--motion-enter) both; } diff --git a/frontend/portal/src/components/Header.tsx b/frontend/portal/src/components/Header.tsx index 59b1a72625..8783e9bafc 100644 --- a/frontend/portal/src/components/Header.tsx +++ b/frontend/portal/src/components/Header.tsx @@ -40,8 +40,12 @@ function ThemeToggle() { } function TierSwitcher() { - const { tier, setTier } = useTier(); + const { tier, setTier, isDerived } = useTier(); const info = TIER_INFO[tier]; + // When mocks are off, the tier is derived from the real link/wallet state — + // pair the dropdown with the mocks toggle (hidden in prod) so testing real + // billing flows can't be perturbed by accidentally flipping the mock tier. + if (isDerived) return null; return ( diff --git a/frontend/portal/src/components/SettingsModal.tsx b/frontend/portal/src/components/SettingsModal.tsx index 0c9ea1d7d7..7cb4ad8c44 100644 --- a/frontend/portal/src/components/SettingsModal.tsx +++ b/frontend/portal/src/components/SettingsModal.tsx @@ -31,7 +31,9 @@ import { PoliciesIcon, InfrastructureIcon, SparklesIcon, + LinkIcon, } from "@portal/components/icons"; +import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; import "@portal/components/SettingsModal.css"; type SettingsSection = @@ -41,7 +43,21 @@ type SettingsSection = | "general" | "authentication" | "sessions" - | "early-access"; + | "early-access" + | "account-link"; + +function isSettingsSection(value: string | null): value is SettingsSection { + return ( + value === "profile" || + value === "appearance" || + value === "notifications" || + value === "general" || + value === "authentication" || + value === "sessions" || + value === "early-access" || + value === "account-link" + ); +} /** Org-wide auth posture the Admin sections edit, mirrored into local state. */ interface SecurityForm { @@ -54,6 +70,12 @@ interface SecurityForm { interface SettingsModalProps { open: boolean; onClose: () => void; + /** + * Optional section to land on when opening. When `null`/unsupported the modal + * picks the default ("profile"). Set by callers like the sidebar's "Link + * account" affordance → "account-link". + */ + initialSection?: string | null; } /** @@ -83,7 +105,11 @@ const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const; * state. Save is a no-op for the demo — it closes — but the theme control * writes straight through to ThemeProvider so the change is real and visible. */ -export function SettingsModal({ open, onClose }: SettingsModalProps) { +export function SettingsModal({ + open, + onClose, + initialSection, +}: SettingsModalProps) { const { t } = useTranslation(); const { tier } = useTier(); const { theme, setTheme } = useTheme(); @@ -124,6 +150,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { { title: t("settings.groups.admin"), items: [ + { + key: "account-link", + label: t("settings.sections.account-link"), + icon: , + }, { key: "authentication", label: t("settings.sections.authentication"), @@ -189,8 +220,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { }, [snapshot]); useEffect(() => { - if (open) setSection("profile"); - }, [open]); + if (!open) return; + const requested = initialSection ?? null; + setSection(isSettingsSection(requested) ? requested : "profile"); + }, [open, initialSection]); const regionOptions = useMemo(() => { if (!snapshot) return []; @@ -301,6 +334,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { } /> )} + + {section === "account-link" && } ); diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx index ac5b042b92..48e7ea0b04 100644 --- a/frontend/portal/src/components/Sidebar.tsx +++ b/frontend/portal/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; +import { useLink } from "@portal/contexts/LinkContext"; import { useAsync } from "@portal/hooks/useAsync"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; import { EDITOR_URL } from "@portal/auth/editorUrl"; @@ -19,6 +20,7 @@ import { ComponentsIcon, InfrastructureIcon, UsageIcon, + LinkIcon, DocsIcon, SettingsIcon, ChevronDownIcon, @@ -47,6 +49,27 @@ const GROUP_PLATFORM: NavEntry[] = [ { id: "docs", icon: }, ]; +/** + * Sidebar-footer link-account CTA. Only visible when the org is unlinked — once + * linked, the linked-instances row + plan badge already communicate the state, + * so a permanent footer button would be noise. Click → opens the login modal + * directly. + */ +function LinkAccountFooterItem() { + const { t } = useTranslation(); + const { openLinkModal } = useUI(); + const { linkState } = useLink(); + if (linkState !== "unlinked") return null; + return ( + } + onClick={() => openLinkModal()} + /> + ); +} + function UsageFooter() { const { tier } = useTier(); const { t } = useTranslation(); @@ -90,8 +113,8 @@ function UsageFooter() { const planLabel = tier === "pro" - ? t("shell.sidebar.planPayAsYouGo") - : t("shell.sidebar.planEnterprise"); + ? t("shell.sidebar.planProcessor", "Processor plan") + : t("shell.sidebar.planEnterprise", "Enterprise plan"); return (
    @@ -200,6 +223,7 @@ export function Sidebar() {
    + ( + () => (linked ? fetchInstances() : Promise.resolve([])), + [reloadKey, linked], + ); + + const [revokingId, setRevokingId] = useState(null); + const [revokeError, setRevokeError] = useState(null); + + const revoke = useCallback(async (instance: LinkedInstanceRow) => { + setRevokingId(instance.instanceId); + setRevokeError(null); + try { + await apiRevokeInstance(instance.instanceId); + setReloadKey((k) => k + 1); + } catch (e) { + setRevokeError(e instanceof Error ? e.message : String(e)); + } finally { + setRevokingId(null); + } + }, []); + + return ( +
    +
    +
    +

    {t("accountLink.panel.sub")}

    +
    + + {t(LINK_INFO[linkState].labelKey)} + +
    + + + + {linked && ( +
    +
    +

    + {t("accountLink.panel.instancesTitle")} +

    +

    + {t("accountLink.panel.instancesSub")} +

    +
    + {instancesState.loading ? ( +
    + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
    + ) : instancesState.error ? ( + + {instancesState.error instanceof HttpError && + instancesState.error.status === 403 + ? t("accountLink.panel.loadError.forbidden") + : t("accountLink.panel.loadError.generic")} + + ) : ( + + )} + + {revokeError && ( + + {revokeError} + + )} +
    + )} +
    + ); +} diff --git a/frontend/portal/src/components/account-link/LinkAccountCard.stories.tsx b/frontend/portal/src/components/account-link/LinkAccountCard.stories.tsx new file mode 100644 index 0000000000..af949e9dc9 --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkAccountCard.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { UseAccountLink } from "@portal/hooks/useAccountLink"; +import { LinkAccountCard } from "@portal/components/account-link/LinkAccountCard"; +import "@portal/views/AccountLink.css"; + +// A no-op UseAccountLink for static stories; overridden per story. +const base: UseAccountLink = { + loginConfigured: true, + status: { linked: false, name: null }, + phase: "idle", + error: null, + completeLink: async () => {}, + unlink: async () => {}, +}; + +const meta: Meta = { + title: "Portal/AccountLink/LinkAccountCard", + component: LinkAccountCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Not linked — the "Link your Stirling account" button opens the login modal. */ +export const NotLinked: Story = { + args: { link: base }, +}; + +/** Linking — login completed, button shows progress while registering. */ +export const Linking: Story = { + args: { link: { ...base, phase: "linking" } }, +}; + +/** Linked — status only; the device secret is never shown. */ +export const Linked: Story = { + args: { + link: { ...base, status: { linked: true, name: "prod-eu-gateway" } }, + }, +}; + +/** SaaS Supabase not configured — explains the in-app dev simulate fallback. */ +export const Unconfigured: Story = { + args: { link: { ...base, loginConfigured: false } }, +}; + +/** Link error surfaced inline. */ +export const Error: Story = { + args: { + link: { + ...base, + phase: "error", + error: "Couldn't register this instance with the SaaS backend.", + }, + }, +}; diff --git a/frontend/portal/src/components/account-link/LinkAccountCard.tsx b/frontend/portal/src/components/account-link/LinkAccountCard.tsx new file mode 100644 index 0000000000..7231fbbe28 --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkAccountCard.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from "react-i18next"; +import { Banner, Button, Card, StatusBadge } from "@shared/components"; +import type { UseAccountLink } from "@portal/hooks/useAccountLink"; +import { useUI } from "@portal/contexts/UIContext"; + +interface Props { + link: UseAccountLink; +} + +/** + * Status + actions for THIS instance's account link. The "Link" button opens + * the single top-level login modal (UIContext.openLinkModal) — never a nested + * modal. The portal posts the returned JWT to the local backend, which stores + * the device secret server-side; the secret is never received or rendered here. + */ +export function LinkAccountCard({ link }: Props) { + const { t } = useTranslation(); + const { openLinkModal } = useUI(); + const linking = link.phase === "linking"; + const linked = link.status?.linked ?? false; + + return ( + +
    +
    + + {t("accountLink.card.eyebrow")} + +

    {t("accountLink.card.title")}

    +
    + + {linked + ? t("accountLink.card.linked") + : t("accountLink.card.notLinked")} + +
    + + {!link.loginConfigured && ( + + {t("accountLink.card.loginNotConfigured.before")}{" "} + VITE_SAAS_SUPABASE_URL{" "} + {t("accountLink.card.loginNotConfigured.after")} + + )} + + {link.error && ( + + {link.error} + + )} + + {linked ? ( +
    + + {link.status?.name + ? t("accountLink.card.linkedAs", { name: link.status.name }) + : t("accountLink.card.linkedGeneric")}{" "} + {t("accountLink.card.billingNote")} + + +
    + ) : ( +
    + +
    + )} +
    + ); +} diff --git a/frontend/portal/src/components/account-link/LinkAccountModal.tsx b/frontend/portal/src/components/account-link/LinkAccountModal.tsx new file mode 100644 index 0000000000..b0dafac4ec --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkAccountModal.tsx @@ -0,0 +1,107 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Modal } from "@shared/components"; +import SupabaseLoginForm from "@shared/auth/ui/SupabaseLoginForm"; +import { + useSupabaseLogin, + type SupabaseLoginSession, +} from "@shared/auth/ui/useSupabaseLogin"; +import "@shared/auth/ui/auth-theme.css"; +import { + ensureSaasSupabase, + isSaasSupabaseConfigured, + PENDING_LINK_KEY, + SAAS_OAUTH_PROVIDERS, +} from "@portal/auth/saasSupabase"; + +interface Props { + open: boolean; + onClose: () => void; + /** + * "link" registers this instance against the signed-in account; "reauth" only + * refreshes an expired SaaS session (the instance is already linked). The mode + * is persisted across the OAuth redirect so the SSO-return handler doesn't + * re-register on a reauth. + */ + mode?: "link" | "reauth"; + /** Called with the SaaS session after a successful sign-in. */ + onLinked: (session: SupabaseLoginSession) => void | Promise; +} + +/** + * In-app account-link login. Signs the admin in to their Stirling (SaaS) account + * via the shared Supabase login (SSO + email/password), then hands the resulting + * session to the caller to register this instance. No popup; the device secret + * never reaches the browser. SSO redirects away and is finished by useAccountLink + * on return. + */ +export function LinkAccountModal({ + open, + onClose, + mode = "link", + onLinked, +}: Props) { + const { t } = useTranslation(); + useEffect(() => { + if (open) ensureSaasSupabase(); + }, [open]); + + const reauth = mode === "reauth"; + const login = useSupabaseLogin({ + providers: SAAS_OAUTH_PROVIDERS, + // Return to the current page after SSO; the SSO-return handler in + // useAccountLink reads the persisted mode so it links vs. only refreshes. + redirectTo: window.location.href, + onBeforeOAuth: () => sessionStorage.setItem(PENDING_LINK_KEY, mode), + onSuccess: async (session) => { + await onLinked(session); + onClose(); + }, + }); + + return ( + + {isSaasSupabaseConfigured ? ( + + ) : ( +
    + + {t("accountLink.modal.loginNotConfigured.before")}{" "} + VITE_SAAS_SUPABASE_URL{" "} + {t("accountLink.modal.loginNotConfigured.and")}{" "} + VITE_SAAS_SUPABASE_ANON_KEY{" "} + {t("accountLink.modal.loginNotConfigured.after")} + + {import.meta.env.DEV && ( + + )} +
    + )} +
    + ); +} diff --git a/frontend/portal/src/components/account-link/LinkGate.stories.tsx b/frontend/portal/src/components/account-link/LinkGate.stories.tsx new file mode 100644 index 0000000000..af2ac5c96b --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkGate.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Card } from "@shared/components"; +import { LinkGate } from "@portal/components/account-link/LinkGate"; + +const meta: Meta = { + title: "Portal/AccountLink/LinkGate", + component: LinkGate, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** + * Gating follows the Link toolbar global: "Unlinked" shows the lock prompt, + * any linked state renders the feature. + */ +export const Default: Story = { + args: { + feature: "AI extraction", + children: ( + A billable feature, unlocked once linked. + ), + }, +}; diff --git a/frontend/portal/src/components/account-link/LinkGate.tsx b/frontend/portal/src/components/account-link/LinkGate.tsx new file mode 100644 index 0000000000..254f4739bc --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkGate.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button } from "@shared/components"; +import { useLink } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; + +interface Props { + /** The billable feature — rendered only when the org is linked. */ + children: ReactNode; + /** Feature name for the lock copy, e.g. "AI extraction". */ + feature?: string; +} + +/** + * Gates billable features on the account-link state. When the org is unlinked it + * renders a "link to unlock" prompt instead of the feature; once linked (free or + * subscribed) the children render. Drop this around any surface that should only + * work against a linked SaaS wallet. + */ +export function LinkGate({ children, feature }: Props) { + const { t } = useTranslation(); + const { featuresUnlocked } = useLink(); + const { openLinkModal } = useUI(); + + if (featuresUnlocked) return <>{children}; + + return ( + openLinkModal()}> + {t("accountLink.gate.action")} + + } + /> + ); +} diff --git a/frontend/portal/src/components/account-link/LinkedInstancesTable.stories.tsx b/frontend/portal/src/components/account-link/LinkedInstancesTable.stories.tsx new file mode 100644 index 0000000000..d10d08bf27 --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkedInstancesTable.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { listInstances, type LinkedInstanceRow } from "@portal/mocks/link"; +import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable"; +import "@portal/views/AccountLink.css"; + +const meta: Meta = { + title: "Portal/AccountLink/LinkedInstancesTable", + component: LinkedInstancesTable, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Active + revoked instances; revoke flips status locally. */ +export const Default: Story = { + render: () => { + const [rows, setRows] = useState(listInstances()); + return ( + + setRows((rs) => + rs.map((r) => + r.instanceId === i.instanceId ? { ...r, revoked: true } : r, + ), + ) + } + /> + ); + }, +}; + +export const Empty: Story = { + args: { instances: [], onRevoke: () => {} }, +}; diff --git a/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx b/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx new file mode 100644 index 0000000000..bfe7fd0258 --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx @@ -0,0 +1,123 @@ +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { + Button, + Card, + EmptyState, + StatusBadge, + Table, + type TableColumn, +} from "@shared/components"; +import type { LinkedInstanceRow } from "@portal/api/link"; + +interface Props { + instances: LinkedInstanceRow[]; + /** Called when the leader revokes a (non-revoked) instance. */ + onRevoke: (instance: LinkedInstanceRow) => void; + /** instanceId currently being revoked — disables its button + shows progress. */ + revokingId?: number | null; +} + +function relativeTime(iso: string | null, t: TFunction): string { + if (!iso) return t("accountLink.instances.time.never"); + const diffMs = Date.now() - new Date(iso).getTime(); + const mins = Math.round(diffMs / 60_000); + if (mins < 1) return t("accountLink.instances.time.justNow"); + if (mins < 60) + return t("accountLink.instances.time.minutesAgo", { count: mins }); + const hrs = Math.round(mins / 60); + if (hrs < 24) return t("accountLink.instances.time.hoursAgo", { count: hrs }); + return t("accountLink.instances.time.daysAgo", { + count: Math.round(hrs / 24), + }); +} + +/** List of linked self-hosted instances with a leader-only revoke action. */ +export function LinkedInstancesTable({ + instances, + onRevoke, + revokingId, +}: Props) { + const { t } = useTranslation(); + const cols: TableColumn[] = [ + { + key: "name", + header: t("accountLink.instances.columns.instance"), + render: (i) => ( +
    + + {i.name ?? t("accountLink.instances.unnamed")} + + {i.deviceId} +
    + ), + }, + { + key: "status", + header: t("accountLink.instances.columns.status"), + render: (i) => + i.revoked ? ( + + {t("accountLink.instances.revoked")} + + ) : ( + + {t("accountLink.instances.active")} + + ), + }, + { + key: "lastSeen", + header: t("accountLink.instances.columns.lastSeen"), + render: (i) => ( + + {relativeTime(i.lastSeenAt, t)} + + ), + }, + { + key: "created", + header: t("accountLink.instances.columns.linked"), + render: (i) => ( + + {relativeTime(i.createdAt, t)} + + ), + }, + { + key: "actions", + header: "", + align: "right", + render: (i) => + i.revoked ? null : ( + + ), + }, + ]; + + return ( + + {instances.length === 0 ? ( + + ) : ( + String(i.instanceId)} + /> + )} + + ); +} diff --git a/frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx b/frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx new file mode 100644 index 0000000000..1e2fb83045 --- /dev/null +++ b/frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/EnterpriseUpsell", + component: EnterpriseUpsell, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Standalone card — used on the free and subscribed billing views. */ +export const Default: Story = {}; + +/** Bare variant — embeds in another card's column (no surface of its own). */ +export const Bare: Story = { args: { bare: true } }; diff --git a/frontend/portal/src/components/billing/EnterpriseUpsell.tsx b/frontend/portal/src/components/billing/EnterpriseUpsell.tsx new file mode 100644 index 0000000000..bff0e91d2c --- /dev/null +++ b/frontend/portal/src/components/billing/EnterpriseUpsell.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import { Button, Card } from "@shared/components"; + +interface Props { + /** Render without the Card wrapper, to embed inside another card's column. */ + bare?: boolean; +} + +/** + * Volume-discount / Enterprise upsell, shared by the free and subscribed billing + * views. The CTA is intentionally inert until the sales/quote URL is confirmed. + */ +export function EnterpriseUpsell({ bare = false }: Props) { + const { t } = useTranslation(); + const body = ( + <> + + {t("billing.enterpriseUpsell.eyebrow")} + +
    +
    +

    + {t("billing.enterpriseUpsell.title")} +

    +

    + {t("billing.enterpriseUpsell.description")} +

    +
    + {/* Destination wired when the enterprise/sales URL is confirmed. */} + +
    + + ); + if (bare) + return
    {body}
    ; + return {body}; +} diff --git a/frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx b/frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx new file mode 100644 index 0000000000..442274431a --- /dev/null +++ b/frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/FreePdfEditorsCard", + component: FreePdfEditorsCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** + * The team editor-fleet card. The metrics are SAMPLE data (flagged with the + * Preview badge) until the fleet-telemetry endpoint lands in a follow-up PR; + * "Invite teammates" is intentionally inert for now. + */ +export const Default: Story = {}; diff --git a/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx b/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx new file mode 100644 index 0000000000..e6eaa65d51 --- /dev/null +++ b/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx @@ -0,0 +1,76 @@ +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + Button, + Card, + MetricCard, + MetricStrip, + StatusBadge, +} from "@shared/components"; +import GroupsIcon from "@mui/icons-material/GroupsRounded"; +import PersonAddIcon from "@mui/icons-material/PersonAddAltRounded"; + +/** + * "Free PDF Editors" team-fleet card. The editors-deployed / active-this-month / + * PDFs-edited figures come from a fleet-telemetry endpoint that does not exist + * yet (tracked for a follow-up PR), so they are SAMPLE data — flagged with a + * Preview badge — and "Invite teammates" is intentionally inert. The layout is + * built so the page matches the marketing design; swap the constants for live + * values when the endpoint lands. + */ +const SAMPLE = { + editorsDeployed: "6", + activeThisMonth: "4", + pdfsEdited: "1,240", +}; + +export function FreePdfEditorsCard() { + const navigate = useNavigate(); + const { t } = useTranslation(); + return ( + +
    +
    + + + +
    +

    + {t("billing.freeEditors.title")}{" "} + + {t("billing.freeEditors.previewBadge")} + +

    +

    + {t("billing.freeEditors.subtitle")} +

    +
    +
    + + + + + + + {/* Opens the Users tab with its invite-member modal (via the ?invite param). */} + +
    +
    + ); +} diff --git a/frontend/portal/src/components/billing/FreePlanView.stories.tsx b/frontend/portal/src/components/billing/FreePlanView.stories.tsx new file mode 100644 index 0000000000..e89219fd3c --- /dev/null +++ b/frontend/portal/src/components/billing/FreePlanView.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FreePlanView } from "@portal/components/billing/FreePlanView"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/FreePlanView", + component: FreePlanView, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Leader — free meter + the "Turn on Processor" CTA (opens embedded checkout on click). */ +export const Leader: Story = { + args: { wallet: freeWallet }, +}; + +/** Member — sees the explainer but not the enable CTA. */ +export const Member: Story = { + args: { wallet: { ...freeWallet, role: "member" } }, +}; diff --git a/frontend/portal/src/components/billing/FreePlanView.tsx b/frontend/portal/src/components/billing/FreePlanView.tsx new file mode 100644 index 0000000000..2d8526d805 --- /dev/null +++ b/frontend/portal/src/components/billing/FreePlanView.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, StatusBadge } from "@shared/components"; +import type { Wallet } from "@portal/api/billing"; +import type { SaasCurrency } from "@portal/billing/stripe"; +import { WalletMeter } from "@portal/components/billing/WalletMeter"; +import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; +import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell"; +import { StripeCheckoutModal } from "@portal/components/billing/StripeCheckoutModal"; + +interface Props { + wallet: Wallet; + /** Called after checkout completes so the parent refetches the wallet. */ + onSubscribed?: () => void; +} + +function isSaasCurrency(c: string | null): c is SaasCurrency { + return c === "usd" || c === "eur" || c === "gbp"; +} + +/** + * Linked, not yet subscribed — the "Editor" current plan. Shows the team's free + * editor fleet, the Processor trial meter (with the inline "Switch on the + * Processor" CTA → embedded Stripe Checkout), and the Enterprise upsell. + */ +export function FreePlanView({ wallet, onSubscribed }: Props) { + const { t } = useTranslation(); + const [modalOpen, setModalOpen] = useState(false); + const [missingTeam, setMissingTeam] = useState(null); + + const isLeader = wallet.role === "leader"; + const currency: SaasCurrency = isSaasCurrency(wallet.currency) + ? wallet.currency + : "usd"; + + function openCheckout() { + if (wallet.teamId == null) { + setMissingTeam(t("billing.freePlan.noTeamResolved")); + return; + } + setMissingTeam(null); + setModalOpen(true); + } + + const switchOnAction = isLeader ? ( + + ) : null; + + return ( +
    + {/* Current plan */} +
    + + {t("billing.freePlan.currentPlan")} + +
    +

    + {t("billing.freePlan.planName")} +

    + + {t("billing.freePlan.freeForever")} + + + {t("billing.freePlan.ssoIncluded")} + + + {t("billing.freePlan.unlimitedUsers")} + +
    +
    + + + + {/* Processor trial — meter with the inline upgrade CTA */} + + + {missingTeam && ( + + {missingTeam} + + )} + {!isLeader && ( +

    + {t("billing.freePlan.ownerOnly")} +

    + )} + + {/* Volume discount / Enterprise */} + + + {wallet.teamId != null && ( + setModalOpen(false)} + teamId={wallet.teamId} + currency={currency} + onComplete={() => { + setModalOpen(false); + onSubscribed?.(); + }} + /> + )} +
    + ); +} diff --git a/frontend/portal/src/components/billing/InvoicesList.tsx b/frontend/portal/src/components/billing/InvoicesList.tsx new file mode 100644 index 0000000000..425bd10582 --- /dev/null +++ b/frontend/portal/src/components/billing/InvoicesList.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Button, + Card, + EmptyState, + Skeleton, + StatusBadge, + Table, + type TableColumn, +} from "@shared/components"; +import { formatMinor, formatPeriodDate } from "@shared/billing"; +import { fetchInvoices, type Invoice } from "@portal/api/billing"; + +const DEFAULT_VISIBLE = 5; +/** Fetch a few more than DEFAULT_VISIBLE so "Show more" actually has something to show. */ +const FETCH_LIMIT = 20; + +function statusTone( + status: string, +): "success" | "warning" | "danger" | "neutral" { + switch (status) { + case "paid": + return "success"; + case "open": + case "draft": + return "warning"; + case "uncollectible": + case "void": + return "danger"; + default: + return "neutral"; + } +} + +/** + * Recent Stripe invoices, sourced from GET /api/v1/payg/invoices (reads + * stripe.invoices via the Sync Engine). Backend orders newest first AND + * filters out drafts (matching Stripe's own customer portal behavior — + * drafts have no public hosted URL or PDF). We fetch FETCH_LIMIT rows and + * show DEFAULT_VISIBLE by default with a "Show all N" inline toggle. For + * history older than FETCH_LIMIT, the Stripe customer portal (button on the + * Subscription card above) is the authoritative archive. + * + * Each row links straight out to Stripe-hosted assets — the invoice page + * ({@code hostedInvoiceUrl}) and the PDF ({@code invoicePdf}). Rendered as + * actual {@code } anchors rather than {@code window.open} + * handlers so they're real user gestures (popup blockers don't trip). + */ +export function InvoicesList() { + const { t } = useTranslation(); + const [invoices, setInvoices] = useState(null); + const [error, setError] = useState(null); + const [showAll, setShowAll] = useState(false); + + useEffect(() => { + let cancelled = false; + setError(null); + fetchInvoices(FETCH_LIMIT) + .then((rows) => { + if (!cancelled) setInvoices(rows); + }) + .catch((e) => { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + const total = invoices?.length ?? 0; + const visible = showAll ? total : Math.min(DEFAULT_VISIBLE, total); + const visibleRows = invoices?.slice(0, visible) ?? []; + const hasMore = total > DEFAULT_VISIBLE; + // We only fetched FETCH_LIMIT rows — at the cap there may be older invoices we + // didn't load, so don't claim "all". + const atFetchLimit = total >= FETCH_LIMIT; + + // Column layout mirrors Stripe's own customer-portal "Invoice history" rows: + // Date · Amount · Status · Description (product name) · Actions + // The monospace invoice id is dropped — users care about "what was it for", + // not the internal id. + const columns: TableColumn[] = [ + { + key: "date", + header: t("billing.invoices.columnDate"), + render: (inv) => + inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—", + }, + { + key: "pdfs", + header: t("billing.invoices.columnPdfsProcessed"), + align: "right", + // Billed units on the invoice's metered line item; "—" when the + // line-item table isn't synced into the Stripe mirror. + render: (inv) => + inv.pdfsProcessed == null ? "—" : inv.pdfsProcessed.toLocaleString(), + }, + { + key: "amount", + header: t("billing.invoices.columnAmount"), + align: "right", + render: (inv) => + inv.totalMinor == null + ? "—" + : formatMinor(inv.totalMinor, inv.currency), + }, + { + key: "status", + header: t("billing.invoices.columnStatus"), + render: (inv) => ( + + {inv.status} + + ), + }, + { + key: "description", + header: t("billing.invoices.columnDescription"), + render: (inv) => ( + + {inv.description ?? t("billing.invoices.descriptionFallback")} + + ), + }, + { + key: "actions", + header: "", + align: "right", + render: (inv) => ( + + ), + }, + ]; + + return ( + +

    + {t("billing.invoices.title")} +

    + + {invoices === null && !error && ( +
    + + + +
    + )} + + {error && ( +

    + {t("billing.invoices.loadError", { error })} +

    + )} + + {invoices !== null && invoices.length === 0 && !error && ( + + )} + + {invoices !== null && invoices.length > 0 && ( + <> +
    inv.id} + /> + {hasMore && ( +
    + + {showAll && atFetchLimit && ( + + {t("billing.invoices.fetchLimitNote", { count: FETCH_LIMIT })} + + )} +
    + )} + + )} + + ); +} diff --git a/frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx b/frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx new file mode 100644 index 0000000000..20a9ab2e80 --- /dev/null +++ b/frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/LinkAccountPrompt", + component: LinkAccountPrompt, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Unlinked billing page — CTA opens the login modal (UIProvider from the preview decorator). */ +export const Default: Story = {}; diff --git a/frontend/portal/src/components/billing/LinkAccountPrompt.tsx b/frontend/portal/src/components/billing/LinkAccountPrompt.tsx new file mode 100644 index 0000000000..72fb366142 --- /dev/null +++ b/frontend/portal/src/components/billing/LinkAccountPrompt.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import { Button, Card, EmptyState } from "@shared/components"; +import { useUI } from "@portal/contexts/UIContext"; + +/** + * Unlinked state — the billing page asks the admin to link their Stirling + * account to claim the 500-PDF free grant. The CTA opens the login modal + * directly (no detour through Settings). + */ +export function LinkAccountPrompt() { + const { t } = useTranslation(); + const { openLinkModal } = useUI(); + return ( + + openLinkModal()}> + {t("billing.linkPrompt.cta")} + + } + /> + + ); +} diff --git a/frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx b/frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx new file mode 100644 index 0000000000..63b492107e --- /dev/null +++ b/frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { PaymentMethodCard } from "@portal/components/billing/PaymentMethodCard"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PaymentMethodCard", + component: PaymentMethodCard, + args: { onManage: () => {} }, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Card present in the Stripe mirror. */ +export const WithCard: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/payment-method", () => + HttpResponse.json({ + present: true, + brand: "visa", + last4: "4242", + expMonth: 8, + expYear: 2027, + }), + ), + ], + }, + }, +}; + +/** Mirror carries no card (table not synced / no card on file) — neutral fallback. */ +export const ManagedInStripe: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/payment-method", () => + HttpResponse.json({ + present: false, + brand: null, + last4: null, + expMonth: null, + expYear: null, + }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/billing/PaymentMethodCard.tsx b/frontend/portal/src/components/billing/PaymentMethodCard.tsx new file mode 100644 index 0000000000..9876a8a4f3 --- /dev/null +++ b/frontend/portal/src/components/billing/PaymentMethodCard.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Card } from "@shared/components"; +import { fetchPaymentMethod, type PaymentMethod } from "@portal/api/billing"; + +interface Props { + /** Opens the Stripe customer portal (card changes live in Stripe, not here). */ + onManage: () => void; + managing?: boolean; +} + +function titleCase(s: string): string { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; +} + +/** + * The team's default card, read from the Stripe mirror via + * {@code GET /api/v1/payg/payment-method}. When the mirror doesn't carry the + * card (table not synced, or no card on file) we don't invent one — we show a + * neutral "managed in Stripe" state. Editing always happens in Stripe's portal; + * the Update button just deep-links there. + */ +export function PaymentMethodCard({ onManage, managing }: Props) { + const { t } = useTranslation(); + // undefined = loading, null = none/unavailable, object = real card. + const [pm, setPm] = useState(undefined); + + useEffect(() => { + let cancelled = false; + fetchPaymentMethod() + .then((p) => { + if (!cancelled) setPm(p.present ? p : null); + }) + .catch(() => { + if (!cancelled) setPm(null); + }); + return () => { + cancelled = true; + }; + }, []); + + const hasCard = pm != null && pm.last4 != null; + + return ( + +
    +
    + + {t("billing.paymentMethod.eyebrow")} + + {hasCard ? ( + <> +

    + {t("billing.paymentMethod.cardEnding", { + brand: titleCase( + pm.brand ?? t("billing.paymentMethod.cardFallback"), + ), + last4: pm.last4, + })} +

    +

    + {pm.expMonth != null && pm.expYear != null + ? t("billing.paymentMethod.expiresBilledMonthly", { + expiry: `${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`, + }) + : t("billing.paymentMethod.billedMonthly")} +

    + + ) : ( + <> +

    + {t("billing.paymentMethod.managedTitle")} +

    +

    + {t("billing.paymentMethod.managedSub")} +

    + + )} +
    + +
    +
    + ); +} diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx b/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx new file mode 100644 index 0000000000..eaf3172bec --- /dev/null +++ b/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard"; +import { subscribedWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PdfsProcessedCard", + component: PdfsProcessedCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Metered PDFs split across API / Agents / Automation (real categoryBreakdown). */ +export const WithBreakdown: Story = { args: { wallet: subscribedWallet } }; + +/** Nothing metered yet this period — the split hides. */ +export const Empty: Story = { + args: { + wallet: { + ...subscribedWallet, + billableUsed: 0, + spendUnitsThisPeriod: 0, + categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + }, + }, +}; diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx new file mode 100644 index 0000000000..1718ea2a19 --- /dev/null +++ b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx @@ -0,0 +1,104 @@ +import { useTranslation } from "react-i18next"; +import { Card } from "@shared/components"; +import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing"; + +/** + * "PDFs processed this period" headline + a stacked split of where the metered + * PDFs went. The split reuses the wallet's existing {@code categoryBreakdown} + * (API / Agents / Automation — the same buckets the entitlement service tracks; + * the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when + * nothing metered has run yet. + */ +const SEGMENTS: ReadonlyArray<{ + key: keyof WalletCategoryBreakdown; + labelKey: string; + descKey: string; + cls: string; +}> = [ + { + key: "api", + labelKey: "billing.pdfsProcessed.segmentApiLabel", + descKey: "billing.pdfsProcessed.segmentApiDesc", + cls: "blue", + }, + { + key: "ai", + labelKey: "billing.pdfsProcessed.segmentAgentsLabel", + descKey: "billing.pdfsProcessed.segmentAgentsDesc", + cls: "purple", + }, + { + key: "automation", + labelKey: "billing.pdfsProcessed.segmentAutomationLabel", + descKey: "billing.pdfsProcessed.segmentAutomationDesc", + cls: "teal", + }, +]; + +export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { + const { t } = useTranslation(); + const b = wallet.categoryBreakdown; + const total = b.api + b.ai + b.automation; + + return ( + + + {t("billing.pdfsProcessed.eyebrow")} + +
    + + {wallet.billableUsed.toLocaleString()} + + + {t("billing.pdfsProcessed.unit")} + +
    + + {total > 0 ? ( + <> +
    + {SEGMENTS.map((s) => + b[s.key] > 0 ? ( + + ) : null, + )} +
    +
    + {SEGMENTS.map((s) => ( +
    + + + {t(s.labelKey)} + + + {t("billing.pdfsProcessed.legendValue", { + count: b[s.key], + formatted: b[s.key].toLocaleString(), + })} + + + {t(s.descKey)} + +
    + ))} +
    + + ) : ( +

    + {t("billing.pdfsProcessed.emptyPeriod")} +

    + )} +
    + ); +} diff --git a/frontend/portal/src/components/billing/SpendLimitCard.stories.tsx b/frontend/portal/src/components/billing/SpendLimitCard.stories.tsx new file mode 100644 index 0000000000..1356dc156a --- /dev/null +++ b/frontend/portal/src/components/billing/SpendLimitCard.stories.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SpendLimitCard } from "@portal/components/billing/SpendLimitCard"; +import { subscribedWallet } from "@portal/components/billing/walletFixtures"; +import type { Wallet } from "@portal/api/billing"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/SpendLimitCard", + component: SpendLimitCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Interactive wrapper so the in-place "Adjust limit" edit toggle works in-story. */ +function Demo({ wallet, open = false }: { wallet: Wallet; open?: boolean }) { + const [adjusting, setAdjusting] = useState(open); + return ( + + ); +} + +/** Comfortably within the cap. */ +export const WithinCap: Story = { + render: () => , +}; + +/** Approaching the cap — % used chip + run-rate projection. */ +export const ApproachingCap: Story = { + render: () => ( + + ), +}; + +/** The in-place editor — buckets + suggested shortcut + guardrail + Save. */ +export const Editing: Story = { + render: () => ( + + ), +}; + +/** Uncapped — no bar, no projection. */ +export const NoCap: Story = { + render: () => ( + + ), +}; diff --git a/frontend/portal/src/components/billing/SpendLimitCard.tsx b/frontend/portal/src/components/billing/SpendLimitCard.tsx new file mode 100644 index 0000000000..d4861dc2d1 --- /dev/null +++ b/frontend/portal/src/components/billing/SpendLimitCard.tsx @@ -0,0 +1,279 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Card } from "@shared/components"; +import { + currencySymbol, + docCapForMoney, + formatMinor, + formatMoneyMajor, + MeterBar, + meterState, + SpendCapControl as SharedSpendCapControl, +} from "@shared/billing"; +import type { Wallet } from "@portal/api/billing"; +import { updateCap } from "@portal/api/billing"; + +interface Props { + wallet: Wallet; + onWalletChange?: () => void; + /** Controlled edit mode — lifted so the over-cap banner can open it. */ + adjusting: boolean; + onAdjustingChange: (open: boolean) => void; +} + +/** Local-date day count between two ISO yyyy-mm-dd strings; 0 if unparseable. */ +function daysBetween(aIso: string, bIso: string): number { + const a = Date.parse(`${aIso}T00:00:00`); + const b = Date.parse(`${bIso}T00:00:00`); + if (Number.isNaN(a) || Number.isNaN(b)) return 0; + return Math.max(0, Math.round((b - a) / 86_400_000)); +} + +interface Projection { + dailyRateMajor: number; + daysToCap: number; + projectedEndMajor: number; + suggestedMajor: number; +} + +/** + * Straight-line cap projection from real data: current spend over elapsed days + * gives a daily run-rate, extrapolated across the period. Returns null unless + * there's a live cap, a known rate, at least a day elapsed, and the trajectory + * actually overshoots — i.e. only when there's something to warn about. + */ +function projectOverspend(wallet: Wallet): Projection | null { + if (wallet.noCap || wallet.capUsd == null) return null; + if (wallet.estimatedBillMinor == null || wallet.estimatedBillMinor <= 0) { + return null; + } + const totalDays = daysBetween( + wallet.billingPeriodStart, + wallet.billingPeriodEnd, + ); + if (totalDays <= 0) return null; + const todayIso = new Date().toISOString().slice(0, 10); + const rawElapsed = daysBetween(wallet.billingPeriodStart, todayIso); + // Need at least a full day of data to extrapolate. On day 0 (or clock skew / a + // UTC-vs-local boundary) rawElapsed is <= 0; projecting then would treat the whole + // period's spend as one day's run-rate and falsely "project to exceed". + if (rawElapsed < 1) return null; + const elapsed = Math.min(totalDays, rawElapsed); + + const spentMajor = wallet.estimatedBillMinor / 100; + const dailyRateMajor = spentMajor / elapsed; + const projectedEndMajor = dailyRateMajor * totalDays; + if (projectedEndMajor <= wallet.capUsd) return null; + + const daysToCap = Math.max( + 1, + Math.ceil((wallet.capUsd - spentMajor) / dailyRateMajor), + ); + const suggestedMajor = Math.ceil((projectedEndMajor * 1.15) / 1000) * 1000; + return { dailyRateMajor, daysToCap, projectedEndMajor, suggestedMajor }; +} + +function persistedCapOf(wallet: Wallet): number | null { + return wallet.noCap ? null : (wallet.capUsd ?? null); +} + +/** + * The cap surface (right card of the spend row). Two in-place modes — display + * (flat spend-vs-cap meter via the shared {@link MeterBar}, % used, projection) + * and edit (the shared bucket {@link SharedSpendCapControl} + suggested-value + * shortcut + guardrail note + Cancel/Save) — swapping in place rather than + * revealing a second card. Leader-only edit; members see display only. + */ +export function SpendLimitCard({ + wallet, + onWalletChange, + adjusting, + onAdjustingChange, +}: Props) { + const { t } = useTranslation(); + const isLeader = wallet.role === "leader"; + const symbol = currencySymbol(wallet.currency); + const persistedCap = persistedCapOf(wallet); + const proj = projectOverspend(wallet); + + const [draftCap, setDraftCap] = useState(persistedCap); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Reseed the draft whenever the editor opens or the persisted value changes. + useEffect(() => { + if (adjusting) { + setDraftCap(persistedCap); + setError(null); + } + }, [adjusting, persistedCap]); + + async function save() { + setSaving(true); + setError(null); + try { + await updateCap(draftCap); + onAdjustingChange(false); + onWalletChange?.(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + } + + // ── Edit mode ─────────────────────────────────────────────────────────── + if (isLeader && adjusting) { + return ( + + + {t("billing.spendLimit.eyebrow")} + +

    + {t("billing.spendLimit.editTitle")} +

    + + + + {proj && draftCap !== proj.suggestedMajor && ( + + )} + +
    + {t("billing.spendLimit.guardrailLabel")}{" "} + {t("billing.spendLimit.guardrailBody")} +
    + + {error && ( + + {error} + + )} + +
    + + +
    +
    + ); + } + + // ── Display mode ──────────────────────────────────────────────────────── + const spentMinor = wallet.estimatedBillMinor ?? 0; + const cap = wallet.capUsd ?? 0; + const capActive = !wallet.noCap && wallet.capUsd != null; + const { state, pct } = meterState(spentMinor / 100, cap); + const remainingMinor = Math.max(0, Math.round(cap * 100) - spentMinor); + const docEstimate = docCapForMoney(wallet.capUsd, wallet.pricePerDocMinor); + const spentLabel = formatMinor(spentMinor, wallet.currency); + + return ( + +
    +
    + + {t("billing.spendLimit.eyebrow")} + +

    + {t("billing.spendLimit.displaySub")} +

    +
    + {isLeader && ( + + )} +
    + +
    + + + {t("billing.spendLimit.usedThisMonth", { + amount: spentLabel, + })} + + + {t("billing.spendLimit.remaining", { + amount: formatMinor(remainingMinor, wallet.currency), + })} + + + ) : ( + + {t("billing.spendLimit.thisPeriodUncapped", { + amount: spentLabel, + })} + + ) + } + /> +
    + + {proj && ( +

    + {t("billing.spendLimit.projection.label")}{" "} + {t("billing.spendLimit.projection.body", { + count: proj.daysToCap, + rate: `${symbol}${proj.dailyRateMajor.toLocaleString(undefined, { + maximumFractionDigits: 2, + })}`, + monthEnd: formatMoneyMajor( + Math.round(proj.projectedEndMajor), + wallet.currency, + ), + suggested: formatMoneyMajor(proj.suggestedMajor, wallet.currency), + })} +

    + )} +
    + ); +} diff --git a/frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx b/frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx new file mode 100644 index 0000000000..a47b3df65b --- /dev/null +++ b/frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SpendThisMonthCard } from "@portal/components/billing/SpendThisMonthCard"; +import { subscribedWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/SpendThisMonthCard", + component: SpendThisMonthCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Actual spend + the Enterprise upsell tacked onto the foot. */ +export const Default: Story = { args: { wallet: subscribedWallet } }; diff --git a/frontend/portal/src/components/billing/SpendThisMonthCard.tsx b/frontend/portal/src/components/billing/SpendThisMonthCard.tsx new file mode 100644 index 0000000000..1989224df9 --- /dev/null +++ b/frontend/portal/src/components/billing/SpendThisMonthCard.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from "react-i18next"; +import { Card } from "@shared/components"; +import { formatMinor } from "@shared/billing"; +import type { Wallet } from "@portal/api/billing"; +import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell"; + +/** + * Actual metered spend this period, with the Enterprise upsell tacked onto the + * foot (matching marketing — not a separate full-width row). Flex column so the + * upsell pins to the bottom and the card matches the spend-limit card's height. + */ +export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) { + const { t } = useTranslation(); + const rateLabel = + wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0 + ? formatMinor(wallet.pricePerDocMinor, wallet.currency) + : null; + + return ( + + + {t("billing.spendThisMonth.eyebrow")} + +
    + + {formatMinor(wallet.estimatedBillMinor ?? 0, wallet.currency)} + +
    +

    + {rateLabel + ? t("billing.spendThisMonth.processedWithRate", { + count: wallet.billableUsed, + formattedCount: wallet.billableUsed.toLocaleString(), + rate: rateLabel, + }) + : t("billing.spendThisMonth.processed", { + count: wallet.billableUsed, + formattedCount: wallet.billableUsed.toLocaleString(), + })} +

    + +
    + +
    +
    + ); +} diff --git a/frontend/portal/src/components/billing/StripeCheckoutModal.tsx b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx new file mode 100644 index 0000000000..ac590adc71 --- /dev/null +++ b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Modal, Skeleton } from "@shared/components"; +import { + EmbeddedCheckout, + EmbeddedCheckoutProvider, +} from "@stripe/react-stripe-js"; +import type { Stripe } from "@stripe/stripe-js"; +import { + createCheckoutSession, + getStripePublishableKey, + type SaasCurrency, +} from "@portal/billing/stripe"; + +interface Props { + open: boolean; + onClose: () => void; + /** Caller's resolved team id. The edge function needs it to scope checkout. */ + teamId: number; + /** "usd" | "eur" | "gbp" — the SaaS PAYG offering's supported set. */ + currency: SaasCurrency; + /** Optional billing email prefill (Stripe locks the field when set). */ + billingOwnerEmail?: string; + /** + * Fired when Stripe (or the mock continue button) signals success. Caller + * refreshes the wallet so the linked-subscribed view takes over. + */ + onComplete: () => void; +} + +/** + * Embedded Stripe Checkout, matching the SaaS web app's PAYG sign-up UX. We + * fetch a {@code client_secret} from the SaaS Supabase edge function then + * mount <EmbeddedCheckoutProvider> inline — no full-page redirect, the + * admin stays in the portal. + * + * If the team is already subscribed the edge function short-circuits to a + * Stripe Customer Portal URL; we open it in a new tab and close the modal. + */ +let stripePromise: Promise | null = null; +function loadStripeOnce(pk: string): Promise { + if (stripePromise === null) { + stripePromise = import("@stripe/stripe-js").then((m) => m.loadStripe(pk)); + } + return stripePromise; +} + +export function StripeCheckoutModal({ + open, + onClose, + teamId, + currency, + billingOwnerEmail, + onComplete, +}: Props) { + const { t } = useTranslation(); + const [clientSecret, setClientSecret] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const publishableKey = getStripePublishableKey(); + + // Mint the checkout session whenever the modal opens for a fresh team/currency. + useEffect(() => { + if (!open) { + // Reset on close so re-opening fetches a fresh session. + setClientSecret(null); + setError(null); + setLoading(true); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + createCheckoutSession({ + teamId, + currency, + successUrl: window.location.href, + cancelUrl: window.location.href, + billingOwnerEmail, + }) + .then((session) => { + if (cancelled) return; + if (session.alreadySubscribed && session.redirectUrl) { + // Team's already on PAYG — bounce them to the management portal + // instead of mounting a checkout iframe with no secret. + window.open(session.redirectUrl, "_blank", "noopener,noreferrer"); + onClose(); + return; + } + if (!session.clientSecret) { + setError(t("billing.checkout.noClientSecret")); + return; + } + setClientSecret(session.clientSecret); + }) + .catch((e) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, teamId, currency, billingOwnerEmail, onClose, t]); + + const stripe = publishableKey ? loadStripeOnce(publishableKey) : null; + const canRender = Boolean(stripe && clientSecret); + + return ( + + {!publishableKey && ( + + {t("billing.checkout.notConfigured.bodyBefore")}{" "} + VITE_STRIPE_PUBLISHABLE_KEY{" "} + {t("billing.checkout.notConfigured.bodyAfter")} + + )} + {publishableKey && error && ( + + {error} + + )} + {publishableKey && loading && !error && ( +
    + + +
    + )} + {publishableKey && canRender && stripe && clientSecret && ( + + + + )} +
    + ); +} diff --git a/frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx b/frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx new file mode 100644 index 0000000000..4083c687f0 --- /dev/null +++ b/frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { SubscribedPlanView } from "@portal/components/billing/SubscribedPlanView"; +import { subscribedWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const card = http.get("*/api/v1/payg/payment-method", () => + HttpResponse.json({ + present: true, + brand: "visa", + last4: "4242", + expMonth: 8, + expYear: 2027, + }), +); + +const invoices = http.get("*/api/v1/payg/invoices", () => + HttpResponse.json([ + { + id: "in_6", + number: "INV-2026-006", + status: "open", + totalMinor: 714235, + currency: "usd", + createdAt: "2026-06-01T00:00:00Z", + periodStart: "2026-06-01T00:00:00Z", + periodEnd: "2026-06-30T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/test_6", + invoicePdf: "https://invoice.stripe.com/i/test_6/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 142847, + }, + { + id: "in_5", + number: "INV-2026-005", + status: "paid", + totalMinor: 691085, + currency: "usd", + createdAt: "2026-05-01T00:00:00Z", + periodStart: "2026-05-01T00:00:00Z", + periodEnd: "2026-05-31T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/test_5", + invoicePdf: "https://invoice.stripe.com/i/test_5/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 138217, + }, + { + id: "in_4", + number: "INV-2026-004", + status: "paid", + totalMinor: 650515, + currency: "usd", + createdAt: "2026-04-01T00:00:00Z", + periodStart: "2026-04-01T00:00:00Z", + periodEnd: "2026-04-30T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/test_4", + invoicePdf: "https://invoice.stripe.com/i/test_4/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 130103, + }, + ]), +); + +const meta: Meta = { + title: "Portal/Billing/SubscribedPlanView", + component: SubscribedPlanView, + parameters: { layout: "padded", msw: { handlers: [card, invoices] } }, +}; +export default meta; +type Story = StoryObj; + +/** The full Processor-plan dashboard — leader, within cap. */ +export const Leader: Story = { args: { wallet: subscribedWallet } }; + +/** Approaching the cap — surfaces the over-cap warning banner + projection. */ +export const ApproachingCap: Story = { + args: { + wallet: { + ...subscribedWallet, + estimatedBillMinor: 85_000, + billableUsed: 42_500, + spendUnitsThisPeriod: 42_500, + }, + }, +}; diff --git a/frontend/portal/src/components/billing/SubscribedPlanView.tsx b/frontend/portal/src/components/billing/SubscribedPlanView.tsx new file mode 100644 index 0000000000..6d9d6bd6f9 --- /dev/null +++ b/frontend/portal/src/components/billing/SubscribedPlanView.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button } from "@shared/components"; +import { meterState } from "@shared/billing"; +import type { Wallet } from "@portal/api/billing"; +import { useStripePortal } from "@portal/hooks/useStripePortal"; +import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; +import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard"; +import { SpendThisMonthCard } from "@portal/components/billing/SpendThisMonthCard"; +import { SpendLimitCard } from "@portal/components/billing/SpendLimitCard"; +import { PaymentMethodCard } from "@portal/components/billing/PaymentMethodCard"; +import { InvoicesList } from "@portal/components/billing/InvoicesList"; + +interface Props { + wallet: Wallet; + onWalletChange?: () => void; +} + +/** + * Linked + subscribed — the full Processor-plan dashboard, matching the + * marketing layout and reusing the free view's building blocks: + * - team editor fleet ({@link FreePdfEditorsCard}, shared with the free view) + * - PDFs processed + category split ({@link PdfsProcessedCard}) + * - spend-vs-cap meter, projection, and the leader-only cap editor + * ({@link SpendLimitCard} → shared {@code SpendCapControl}) + * - Enterprise upsell ({@link EnterpriseUpsell}, shared with the free view) + * - per-member usage, Stripe invoices, and the default payment method + * + * Card / subscription management lives in Stripe's hosted portal — both the + * page-header "Manage Payment" action and the payment card's "Update" button + * deep-link there via {@link useStripePortal}. + */ +export function SubscribedPlanView({ wallet, onWalletChange }: Props) { + const { t } = useTranslation(); + const [adjusting, setAdjusting] = useState(false); + const portal = useStripePortal(wallet); + + const isLeader = wallet.role === "leader"; + const spent = + wallet.estimatedBillMinor != null ? wallet.estimatedBillMinor / 100 : 0; + const capActive = !wallet.noCap && wallet.capUsd != null; + const { state, pct } = meterState(spent, wallet.capUsd ?? 0); + const showCapWarn = capActive && state !== "FULL"; + + function raiseLimit() { + setAdjusting(true); + document + .getElementById("portal-spend-limit") + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + return ( +
    + {showCapWarn && ( + + {t("billing.subscribedPlan.capWarn.raiseLimit")} + + ) : undefined + } + > + {state === "DEGRADED" + ? t("billing.subscribedPlan.capWarn.reachedBody") + : t("billing.subscribedPlan.capWarn.approachingBody")} + + )} + + + + + +
    + + +
    + + + + + + {portal.error && ( + + {portal.error} + + )} +
    + ); +} diff --git a/frontend/portal/src/components/billing/WalletMeter.stories.tsx b/frontend/portal/src/components/billing/WalletMeter.stories.tsx new file mode 100644 index 0000000000..b43bcf8a07 --- /dev/null +++ b/frontend/portal/src/components/billing/WalletMeter.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { WalletMeter } from "@portal/components/billing/WalletMeter"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/WalletMeter", + component: WalletMeter, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Free grant, plenty left (< 80% used) → FULL band. */ +export const FreePlentyLeft: Story = { + // The band keys off used/allowance — 120/500 = 24% → FULL. + args: { wallet: { ...freeWallet, billableUsed: 120, freeRemaining: 380 } }, +}; + +/** Free grant approaching the limit (≥ 80%) → WARNED band. */ +export const FreeApproachingLimit: Story = { + args: { wallet: { ...freeWallet, billableUsed: 440, freeRemaining: 60 } }, +}; + +/** Free grant exhausted → DEGRADED band. */ +export const FreeLimitReached: Story = { + args: { wallet: { ...freeWallet, billableUsed: 500, freeRemaining: 0 } }, +}; diff --git a/frontend/portal/src/components/billing/WalletMeter.tsx b/frontend/portal/src/components/billing/WalletMeter.tsx new file mode 100644 index 0000000000..506faffb8b --- /dev/null +++ b/frontend/portal/src/components/billing/WalletMeter.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Card } from "@shared/components"; +import { formatMinor, MeterBar, meterState } from "@shared/billing"; +import type { Wallet } from "@portal/api/billing"; + +interface Props { + /** A linked-free wallet. */ + wallet: Wallet; + /** Optional top-right action (e.g. "Switch on the Processor"). */ + action?: ReactNode; +} + +/** + * The free Processor-trial meter — "X / N free PDFs used" against the one-time + * grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the + * cloud plan page). The subscribed spend-vs-cap meter is a separate surface + * ({@code SpendLimitCard}); this card is only the free face. + */ +export function WalletMeter({ wallet, action }: Props) { + const { t } = useTranslation(); + const { state, pct } = meterState(wallet.billableUsed, wallet.freeAllowance); + const rate = + wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0 + ? wallet.pricePerDocMinor + : null; + const title = + rate != null + ? t("billing.walletMeter.titleWithRate", { + count: wallet.freeAllowance, + allowance: wallet.freeAllowance.toLocaleString(), + rate: formatMinor(rate, wallet.currency), + }) + : t("billing.walletMeter.title", { + count: wallet.freeAllowance, + allowance: wallet.freeAllowance.toLocaleString(), + }); + + return ( + +
    +
    + + {t("billing.walletMeter.eyebrow")} + +

    {title}

    +

    + {t("billing.walletMeter.sub")} +

    +
    + {action} +
    +
    + +
    +
    + ); +} diff --git a/frontend/portal/src/components/billing/billing.css b/frontend/portal/src/components/billing/billing.css new file mode 100644 index 0000000000..ee1f593833 --- /dev/null +++ b/frontend/portal/src/components/billing/billing.css @@ -0,0 +1,999 @@ +/* Portal billing page — paired with views/Usage.tsx (.portal-billing root). */ + +.portal-billing__stack { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Spend this month | Spend limit — two separate cards, side by side. */ +.portal-billing__spend-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); + gap: 0.75rem; + align-items: stretch; +} +@media (max-width: 60rem) { + .portal-billing__spend-row { + grid-template-columns: 1fr; + } +} + +/* Left card is a flex column so the Enterprise upsell pins to the foot and the + card matches the spend-limit card's height. */ +.portal-billing__spend-this-month { + display: flex; + flex-direction: column; +} +.portal-billing__spend-foot { + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} +.portal-billing__spend-foot:not(:first-child) { + margin-top: 1.25rem; +} + +/* Cap editor (in-place, replaces the spend-limit display). */ +.portal-billing__suggested { + display: inline-flex; + align-items: center; + margin-top: 0.75rem; + padding: 0.3rem 0.7rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-blue); + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: 999px; + cursor: pointer; +} +.portal-billing__suggested:hover { + border-color: var(--color-blue); +} +.portal-billing__guardrail { + margin-top: 0.85rem; + padding: 0.7rem 0.85rem; + font-size: 0.8125rem; + color: var(--color-text-3); + background: var(--color-bg-subtle); + border-radius: 0.6rem; +} +.portal-billing__guardrail strong { + color: var(--color-text-1); +} +.portal-billing__edit-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} +/* Tighten the billing cards to the marketing rhythm — less empty top/bottom and + slightly snugger sides than the default loose profile. */ +.portal-billing__stack .sui-card--pad-loose { + padding: 0.95rem 1.5rem; +} + +.portal-billing__row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; +} + +@media (max-width: 900px) { + .portal-billing__row { + grid-template-columns: 1fr; + } +} + +.portal-billing__row-actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 1rem; +} + +.portal-billing__eyebrow { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-3); + margin-bottom: 0.25rem; +} + +.portal-billing__section-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--color-text-1); + margin: 0 0 0.25rem; +} + +.portal-billing__section-sub { + font-size: 0.875rem; + color: var(--color-text-3); + margin: 0 0 1rem; +} + +.portal-billing__skeleton { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-billing__error { + color: var(--color-red, #b91c1c); + font-size: 0.875rem; + margin: 0.5rem 0; +} + +/* ── Meter card ─────────────────────────────────────────────────────────── */ +.portal-billing__meter-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.portal-billing__meter-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--color-text-1); +} + +.portal-billing__meter-figures { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.portal-billing__meter-figure { + display: flex; + flex-direction: column; +} + +.portal-billing__meter-num { + font-size: 1.75rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-billing__meter-num--muted { + color: var(--color-text-3); +} + +.portal-billing__meter-label { + font-size: 0.8125rem; + color: var(--color-text-3); +} + +.portal-billing__meter-track { + height: 0.5rem; + background: var(--color-bg-subtle, #e5e7eb); + border-radius: 999px; + overflow: hidden; + margin-bottom: 0.75rem; +} + +.portal-billing__meter-fill { + height: 100%; + background: linear-gradient( + 90deg, + var(--color-blue, #0a8bff), + var(--color-purple, #8b5cf6) + ); + border-radius: 999px; + transition: width 200ms ease; +} + +.portal-billing__meter-foot { + font-size: 0.8125rem; + color: var(--color-text-3); + margin: 0.75rem 0 0; +} + +/* ── SaaS-shared meter primitives ─────────────────────────────────────────── + Same class names + structure as + editor/src/cloud/components/shared/config/configSections/{Payg,PaygFree}.css + so the visual treatment matches the SaaS plan page. When the shared move + lands these can be deduped via a shared stylesheet. */ +/* Flat by default — the meter sits directly on its card, no inner panel box + (shared by the free trial meter and the subscribed spend meter). */ +.paygf-meter { + margin-top: 0.6rem; +} +.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(--color-text-1, #0f172a); + font-variant-numeric: tabular-nums; +} +.paygf-meter__cap { + font-size: 0.85rem; + color: var(--color-text-3, #64748b); + 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(--color-text-2, #475569); +} + +/* Status chip (Healthy / Approaching / Cap reached). Solid hex colours + intentionally — the chip palette is semantic, not theme-bound. */ +.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: #dcfce7; + color: #15803d; +} +.payg-status[data-state="FULL"] .payg-status__dot { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); +} +.payg-status[data-state="WARNED"] { + background: #fef3c7; + color: #a16207; +} +.payg-status[data-state="WARNED"] .payg-status__dot { + background: #eab308; + box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2); +} +.payg-status[data-state="DEGRADED"] { + background: #fee2e2; + color: #b91c1c; +} +.payg-status[data-state="DEGRADED"] .payg-status__dot { + background: #ef4444; + 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(--color-bg-subtle, #e5e7eb); + 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); +} + +/* ── Plan card (free state) ─────────────────────────────────────────────── */ +.portal-billing__plan-card { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-billing__plan-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--color-text-1); +} + +.portal-billing__plan-sub { + font-size: 0.9375rem; + color: var(--color-text-2); + margin: 0 0 0.5rem; +} + +.portal-billing__plan-features { + list-style: none; + padding: 0; + margin: 0 0 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-billing__plan-features li { + font-size: 0.875rem; + color: var(--color-text-2); + padding-left: 1.25rem; + position: relative; +} + +.portal-billing__plan-features li::before { + content: "✓"; + color: var(--color-green, #10b981); + position: absolute; + left: 0; + font-weight: 600; +} + +.portal-billing__plan-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; +} + +.portal-billing__plan-reassure { + font-size: 0.75rem; + color: var(--color-text-3); +} + +.portal-billing__plan-readonly { + font-size: 0.875rem; + color: var(--color-text-3); + font-style: italic; + margin: 0; +} + +/* ── Category breakdown ─────────────────────────────────────────────────── */ +.portal-billing__breakdown { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-billing__breakdown-row { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.portal-billing__breakdown-head { + display: flex; + justify-content: space-between; + font-size: 0.8125rem; + color: var(--color-text-2); +} + +.portal-billing__breakdown-value { + color: var(--color-text-3); +} + +.portal-billing__breakdown-track { + height: 0.5rem; + background: var(--color-bg-subtle, #e5e7eb); + border-radius: 999px; + overflow: hidden; +} + +.portal-billing__breakdown-fill { + height: 100%; + border-radius: 999px; + transition: width 200ms ease; +} + +.portal-billing__breakdown-fill--blue { + background: #0a8bff; +} + +.portal-billing__breakdown-fill--purple { + background: #8b5cf6; +} + +.portal-billing__breakdown-fill--teal { + background: #06b6d4; +} + +/* ── Cap control ────────────────────────────────────────────────────────── */ +.portal-billing__cap-row { + display: flex; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.portal-billing__cap-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8125rem; + color: var(--color-text-3); +} + +.portal-billing__cap-input { + width: 8rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border, #d1d5db); + border-radius: 0.375rem; + font-size: 0.9375rem; + font-family: inherit; +} + +.portal-billing__cap-nocap { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--color-text-2); + cursor: pointer; +} + +.portal-billing__cap-actions { + display: flex; + justify-content: flex-end; +} + +/* ── Members + invoices ─────────────────────────────────────────────────── */ +.portal-billing__member-stack { + display: flex; + flex-direction: column; +} + +.portal-billing__member-name { + font-weight: 600; + color: var(--color-text-1); +} + +.portal-billing__member-email { + font-size: 0.8125rem; + color: var(--color-text-3); +} + +.portal-billing__invoice-num { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.875rem; +} + +.portal-billing__invoice-desc { + color: var(--color-text-1, #0f172a); + font-size: 0.9375rem; +} + +/* Tables that sit flush inside an already-surfaced Card: drop the table's + own border + background + radius so only the row dividers (kept by sui-table + defaults) show. Column headers are hidden too — small self-evident tables + read better without them. Used by the Invoice history + Per-member usage + tables. */ +.portal-billing__flush-table { + border: none; + background: transparent; + border-radius: 0; +} +.portal-billing__flush-table thead { + display: none; +} + +.portal-billing__subscription-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.portal-billing__subscription-head > div:first-child { + flex: 1 1 auto; + min-width: 0; +} + +.portal-billing__subscription-head + > div:first-child + .portal-billing__section-sub { + margin-bottom: 0; +} + +/* ── Spend-cap control — ported from the SaaS cloud SpendCapControl.css. + Same class names + structure so the visual is identical to the SaaS Plan + page; when the shared-component move lands, these dedupe to one stylesheet. */ +.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(--color-bg-muted, #f8fafc); + --scc-chip-border: var(--color-border, #e2e8f0); + --scc-text-primary: var(--color-text-1, #0f172a); + --scc-text-secondary: var(--color-text-2, #475569); + --scc-text-muted: var(--color-text-3, #64748b); + --scc-border-strong: var(--color-text-4, #94a3b8); + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 0.75rem; +} +[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; +} + +.scc-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.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(--scc-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(--scc-text-primary); + border-color: var(--scc-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; +} + +.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(--scc-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(--scc-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(--scc-text-primary); + font: inherit; + font-size: 0.85rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.scc-custom__input::placeholder { + color: var(--scc-text-muted); + font-weight: 600; +} + +.scc-row__spacer { + margin-left: auto; +} + +.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(--scc-text-primary); +} +.scc-estimate__sub { + font-size: 0.75rem; + color: var(--scc-text-muted); + margin-top: 1px; +} + +.scc-note { + font-size: 0.8125rem; + color: var(--scc-text-muted); + line-height: 1.45; +} + +/* ── Plan header (free-vs-metered split) — ported from SaaS payg-planhead ── */ +.portal-billing__planhead-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.portal-billing__planhead-eyebrow { + font-size: 0.78rem; + color: var(--color-text-3); +} +.portal-billing__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; +} +.portal-billing__role-pill[data-leader="true"] { + background: rgba(10, 139, 255, 0.12); + color: #0a8bff; + border: 1px solid rgba(10, 139, 255, 0.25); +} +.portal-billing__role-pill[data-leader="false"] { + background: var(--color-bg-muted); + color: var(--color-text-3); + border: 1px solid var(--color-border); +} +.portal-billing__planhead-split { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); +} +.portal-billing__planhead-col { + padding-right: 22px; +} +.portal-billing__planhead-col--meter { + padding-right: 0; + padding-left: 22px; + border-left: 1px solid var(--color-border); +} +.portal-billing__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; +} +.portal-billing__planhead-lbl--free { + color: #10b981; +} +.portal-billing__planhead-lbl--meter { + color: #0a8bff; +} +.portal-billing__planhead-title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: var(--color-text-1); + letter-spacing: -0.01em; + line-height: 1.25; +} +.portal-billing__planhead-body { + margin: 5px 0 0; + font-size: 0.85rem; + color: var(--color-text-3); + line-height: 1.5; +} +/* The period meter merged into the plan-head card, divided from the split. */ +.portal-billing__planhead-meter { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--color-border); +} +@media (max-width: 640px) { + .portal-billing__planhead-split { + grid-template-columns: 1fr; + gap: 16px; + } + .portal-billing__planhead-col { + padding-right: 0; + } + .portal-billing__planhead-col--meter { + padding-left: 0; + padding-top: 16px; + border-left: none; + border-top: 1px solid var(--color-border); + } +} + +.portal-billing__invoice-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; +} + +.portal-billing__invoice-link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1, #0f172a); + text-decoration: none; + border: 1px solid transparent; + transition: + background 120ms ease, + border-color 120ms ease; +} + +.portal-billing__invoice-link:hover { + background: var(--color-bg-muted, #f1f5f9); + border-color: var(--color-border, #e2e8f0); +} + +.portal-billing__invoice-link:focus-visible { + outline: none; + border-color: var(--color-blue, #0a8bff); + background: var(--color-bg-muted, #f1f5f9); +} + +.portal-billing__invoice-footer { + display: flex; + justify-content: center; + margin-top: 0.75rem; +} + +/* ── Current plan header ──────────────────────────────────────────────── */ +.portal-billing__current-plan { + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.portal-billing__current-plan-row { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; +} +.portal-billing__current-plan-name { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-1); +} + +/* ── Free PDF Editors (fleet) card ───────────────────────────────────── */ +.portal-billing__editors-id { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} +.portal-billing__editors-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + flex-shrink: 0; + line-height: 0; + border-radius: 0.65rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + color: var(--color-blue); +} +/* Identity, stats, and the "Invite teammates" action all sit on one line; the + metric cells render flat (no per-stat box) and are divided by hairlines, so + the card reads as a single strip rather than a box-in-a-box. */ +.portal-billing__fleet-row { + display: flex; + align-items: center; + gap: 2rem; +} +.portal-billing__fleet-row .portal-billing__editors-id { + align-items: center; + flex: 0 0 auto; +} +.portal-billing__fleet-metrics { + flex: 1; +} +.portal-billing__fleet-metrics .sui-metric { + background: transparent; + border: 0; + box-shadow: none; + border-left: 1px solid var(--color-border); + padding: 0 0 0 1.5rem; + min-width: 0; + gap: 0.3rem; +} +/* Uppercase stat labels to match the marketing treatment. */ +.portal-billing__fleet-metrics .sui-metric__label { + text-transform: uppercase; + letter-spacing: 0.05em; +} +@media (max-width: 60rem) { + .portal-billing__fleet-row { + flex-wrap: wrap; + gap: 1.25rem; + } + .portal-billing__fleet-metrics { + flex: 1 1 100%; + order: 3; + } +} + +/* "N left" reads as quiet gray text, like the "x of y" suffix — not a status pill. */ +.portal-billing__trial-meter .payg-status, +.portal-billing__spend-meter .payg-status { + background: none; + padding: 0; + border-radius: 0; + font-size: 0.85rem; + font-weight: 400; + color: var(--color-text-3, #64748b); +} +.portal-billing__trial-meter .payg-status__dot, +.portal-billing__spend-meter .payg-status__dot { + display: none; +} +/* Spend meter: anchor the "remaining" half of the meta line to the right edge. */ +.portal-billing__spend-meter .paygf-meter__meta { + justify-content: space-between; +} + +/* ── Enterprise / volume-discount card ───────────────────────────────── */ +.portal-billing__enterprise-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} +/* Bare variant — embeds in another card's column without its own surface. */ +.portal-billing__enterprise-bare { + display: block; +} + +/* ── PDFs processed + category split (subscribed) ────────────────────── */ +.portal-billing__bignum-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin: 0.35rem 0 0.85rem; +} +.portal-billing__bignum { + font-size: 2rem; + font-weight: 750; + line-height: 1; + letter-spacing: -0.02em; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} +.portal-billing__bignum-unit { + font-size: 0.85rem; + color: var(--color-text-3); +} +.portal-billing__segbar { + display: flex; + height: 0.55rem; + border-radius: 999px; + overflow: hidden; + background: var(--color-bg-muted, #f1f5f9); +} +.portal-billing__segbar-seg { + height: 100%; +} +.portal-billing__segbar-seg--blue { + background: #0a8bff; +} +.portal-billing__segbar-seg--purple { + background: #8b5cf6; +} +.portal-billing__segbar-seg--teal { + background: #06b6d4; +} +.portal-billing__seglegend { + display: flex; + flex-wrap: wrap; + gap: 0.6rem 1.75rem; + margin-top: 0.85rem; +} +.portal-billing__seglegend-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + font-size: 0.85rem; +} +.portal-billing__dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + align-self: center; + flex-shrink: 0; +} +.portal-billing__dot--blue { + background: #0a8bff; +} +.portal-billing__dot--purple { + background: #8b5cf6; +} +.portal-billing__dot--teal { + background: #06b6d4; +} +.portal-billing__seglegend-label { + font-weight: 600; + color: var(--color-text-1); +} +.portal-billing__seglegend-val { + color: var(--color-text-2); + font-variant-numeric: tabular-nums; +} +.portal-billing__seglegend-desc { + color: var(--color-text-4); +} + +/* ── Spend-limit projection line ─────────────────────────────────────── */ +.portal-billing__projection { + margin: 0.85rem 0 0; + font-size: 0.85rem; + color: var(--color-text-3); +} +.portal-billing__projection strong { + color: var(--color-amber, #d97706); +} diff --git a/frontend/portal/src/components/billing/walletFixtures.ts b/frontend/portal/src/components/billing/walletFixtures.ts new file mode 100644 index 0000000000..c751f1269a --- /dev/null +++ b/frontend/portal/src/components/billing/walletFixtures.ts @@ -0,0 +1,60 @@ +import type { Wallet } from "@portal/api/billing"; + +/** Linked, on the one-time free grant — leader view. Override per story. */ +export const freeWallet: Wallet = { + teamId: 42, + status: "free", + role: "leader", + billingPeriodStart: "2026-06-01", + billingPeriodEnd: "2026-06-30", + billableUsed: 120, + billableLimit: 500, + freeAllowance: 500, + freeRemaining: 380, + pricePerDocMinor: 2, + currency: "usd", + estimatedBillMinor: null, + capUsd: null, + noCap: false, + stripeSubscriptionId: null, + spendUnitsThisPeriod: 120, + categoryBreakdown: { api: 40, ai: 30, automation: 50 }, + members: [], + recent: [], +}; + +/** Linked + subscribed (Processor plan), capped, leader view with members. */ +export const subscribedWallet: Wallet = { + teamId: 42, + status: "subscribed", + role: "leader", + billingPeriodStart: "2026-06-01", + billingPeriodEnd: "2026-06-30", + billableUsed: 2250, + billableLimit: 50000, + freeAllowance: 500, + freeRemaining: 0, + pricePerDocMinor: 2, + currency: "usd", + estimatedBillMinor: 4500, + capUsd: 1000, + noCap: false, + stripeSubscriptionId: "sub_123", + spendUnitsThisPeriod: 2250, + categoryBreakdown: { api: 900, ai: 600, automation: 750 }, + members: [ + { + userId: "u1", + name: "Ada Lovelace", + email: "ada@acme.test", + spendUnits: 1400, + }, + { + userId: "u2", + name: "Alan Turing", + email: "alan@acme.test", + spendUnits: 850, + }, + ], + recent: [], +}; diff --git a/frontend/portal/src/components/icons.tsx b/frontend/portal/src/components/icons.tsx index 67051cab1f..84d66c9cb3 100644 --- a/frontend/portal/src/components/icons.tsx +++ b/frontend/portal/src/components/icons.tsx @@ -243,3 +243,12 @@ export function AgentBuilderIcon(props: IconProps) { ); } + +export function LinkIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/frontend/portal/src/components/usage/AvailablePlans.stories.tsx b/frontend/portal/src/components/usage/AvailablePlans.stories.tsx deleted file mode 100644 index 4b172d0ad7..0000000000 --- a/frontend/portal/src/components/usage/AvailablePlans.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AvailablePlans } from "@portal/components/usage/AvailablePlans"; -import { PLAN_OPTIONS } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/AvailablePlans", - component: AvailablePlans, - args: { plans: PLAN_OPTIONS, onSelect: () => {} }, -}; -export default meta; -type Story = StoryObj; - -export const OnFree: Story = { args: { current: "free" } }; - -export const OnPro: Story = { args: { current: "pro" } }; - -export const OnEnterprise: Story = { args: { current: "enterprise" } }; diff --git a/frontend/portal/src/components/usage/AvailablePlans.tsx b/frontend/portal/src/components/usage/AvailablePlans.tsx deleted file mode 100644 index f82285f2ee..0000000000 --- a/frontend/portal/src/components/usage/AvailablePlans.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useTranslation } from "react-i18next"; -import type { Tier } from "@portal/contexts/TierContext"; -import type { PlanOption } from "@portal/api/usage"; -import { PlanCard } from "@portal/components/usage/PlanCard"; -import "@portal/views/Usage.css"; - -/** The plan-catalogue grid, marking the caller's current tier. */ -export function AvailablePlans({ - plans, - current, - onSelect, -}: { - plans: PlanOption[]; - current: Tier; - onSelect: (plan: PlanOption) => void; -}) { - const { t } = useTranslation(); - return ( -
    -
    -

    - {t("usage.plans.title")} -

    -

    {t("usage.plans.subtitle")}

    -
    -
    - {plans.map((plan) => ( - onSelect(plan)} - /> - ))} -
    -
    - ); -} diff --git a/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx b/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx deleted file mode 100644 index c5144d62ef..0000000000 --- a/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { BillingHistoryTable } from "@portal/components/usage/BillingHistoryTable"; -import { buildBillingHistory } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/BillingHistoryTable", - component: BillingHistoryTable, -}; -export default meta; -type Story = StoryObj; - -// Free: usage tally lines, no charges. -export const Free: Story = { globals: { tier: "free" } }; - -// Pro: platform fee + metered overage rows across cycles. -export const Pro: Story = { globals: { tier: "pro" } }; - -// Enterprise: committed draws plus a goodwill credit (negative amount). -export const Enterprise: Story = { globals: { tier: "enterprise" } }; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/billing/history", async () => { - await delay("infinite"); - return HttpResponse.json(buildBillingHistory("pro")); - }), - ], - }, - }, -}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [http.get("/v1/billing/history", () => HttpResponse.json([]))], - }, - }, -}; diff --git a/frontend/portal/src/components/usage/BillingHistoryTable.tsx b/frontend/portal/src/components/usage/BillingHistoryTable.tsx deleted file mode 100644 index eb5ec8a04b..0000000000 --- a/frontend/portal/src/components/usage/BillingHistoryTable.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Card, - EmptyState, - Skeleton, - StatusBadge, - Table, - type StatusTone, - type TableColumn, -} from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchBillingHistory, - type BillingHistoryRow, - type InvoiceStatus, -} from "@portal/api/usage"; -import { USD, formatBillingDate } from "@portal/components/usage/format"; -import "@portal/views/Usage.css"; - -const STATUS_TONE: Record = { - paid: "success", - due: "warning", - pending: "info", - refunded: "neutral", -}; - -/** Invoice / line-item history for the current and prior billing cycles. */ -export function BillingHistoryTable() { - const { t } = useTranslation(); - const { tier } = useTier(); - - const statusLabel: Record = { - paid: t("usage.history.status.paid"), - due: t("usage.history.status.due"), - pending: t("usage.history.status.pending"), - refunded: t("usage.history.status.refunded"), - }; - const state = useAsync( - () => fetchBillingHistory(tier), - [tier], - ); - const { data: rows } = state; - const { isLoading, isEmpty } = useSectionFlags(state); - - const columns: TableColumn[] = [ - { - key: "date", - header: t("usage.history.columns.date"), - render: (r) => ( - - {formatBillingDate(r.date)} - - ), - width: "9rem", - }, - { - key: "description", - header: t("usage.history.columns.description"), - render: (r) => r.description, - }, - { - key: "docs", - header: t("usage.history.columns.docs"), - align: "right", - render: (r) => (r.docs > 0 ? r.docs.toLocaleString() : "—"), - width: "8rem", - }, - { - key: "amount", - header: t("usage.history.columns.amount"), - align: "right", - render: (r) => ( - - {r.amount < 0 - ? `−${USD.format(Math.abs(r.amount))}` - : USD.format(r.amount)} - - ), - width: "8rem", - }, - { - key: "status", - header: t("usage.history.columns.status"), - align: "right", - render: (r) => ( - - {statusLabel[r.status]} - - ), - width: "8rem", - }, - ]; - - return ( -
    -
    -

    - {t("usage.history.title")} -

    -

    - {t("usage.history.subtitle")} -

    -
    - - {isLoading && ( -
    - {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
    - )} - - {isEmpty && ( - - )} - - {rows && rows.length > 0 && ( - -
    r.id} - empty={t("usage.history.emptyRows")} - /> - - )} - - ); -} diff --git a/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx b/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx deleted file mode 100644 index 84f50d95aa..0000000000 --- a/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { BillingKpiStrip } from "@portal/components/usage/BillingKpiStrip"; -import { buildBillingSummary } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/BillingKpiStrip", - component: BillingKpiStrip, -}; -export default meta; -type Story = StoryObj; - -// The "remaining in plan" KPI replaces overage on free. -export const Free: Story = { - args: { summary: buildBillingSummary("free") }, - globals: { tier: "free" }, -}; - -// Pro surfaces metered overage cost + docs past cap. -export const Pro: Story = { - args: { summary: buildBillingSummary("pro") }, - globals: { tier: "pro" }, -}; - -// Enterprise swaps overage for commit utilisation. -export const Enterprise: Story = { - args: { summary: buildBillingSummary("enterprise") }, - globals: { tier: "enterprise" }, -}; - -// Summary still loading — every metric falls back to an em dash. -export const Loading: Story = { - args: { summary: null }, - globals: { tier: "pro" }, -}; diff --git a/frontend/portal/src/components/usage/BillingKpiStrip.tsx b/frontend/portal/src/components/usage/BillingKpiStrip.tsx deleted file mode 100644 index 9ed9e6024a..0000000000 --- a/frontend/portal/src/components/usage/BillingKpiStrip.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { MetricCard, MetricStrip } from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import { OVERAGE_RATE, type BillingSummary } from "@portal/api/usage"; -import { USD, formatBillingDate } from "@portal/components/usage/format"; -import "@portal/views/Usage.css"; - -/** Headline billing KPIs: docs processed, cost, the tier-relevant cap figure, and renewal. */ -export function BillingKpiStrip({ - summary, -}: { - summary: BillingSummary | null; -}) { - const { t } = useTranslation(); - const { tier } = useTier(); - - // Overage is meaningless on free (gated) / enterprise (committed) — surface - // the more relevant headline figure for those tiers instead. - const overageCard = - tier === "free" - ? { - label: t("usage.kpi.remainingInPlan.label"), - value: summary - ? `${(summary.includedDocs - summary.docsThisPeriod).toLocaleString()}` - : "—", - description: t("usage.kpi.remainingInPlan.description"), - } - : tier === "enterprise" - ? { - label: t("usage.kpi.commitUtilisation.label"), - value: summary - ? `${Math.round((summary.docsThisPeriod / summary.includedDocs) * 100)}%` - : "—", - description: t("usage.kpi.commitUtilisation.description"), - } - : { - label: t("usage.kpi.overage.label", { - rate: OVERAGE_RATE.toFixed(2), - }), - value: summary ? USD.format(summary.overageCost) : "—", - description: summary - ? t("usage.kpi.overage.description", { - count: summary.overageDocs, - docs: summary.overageDocs.toLocaleString(), - }) - : undefined, - }; - - return ( - - - 0 - ? t("usage.kpi.costThisMonth.description", { - fee: USD.format(summary.monthlyFee), - }) - : tier === "free" - ? t("usage.kpi.costThisMonth.freePlan") - : undefined - } - /> - - - - ); -} diff --git a/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx b/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx deleted file mode 100644 index 1c75228913..0000000000 --- a/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CurrentPlanCard } from "@portal/components/usage/CurrentPlanCard"; -import { buildBillingSummary } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/CurrentPlanCard", - component: CurrentPlanCard, - args: { onUpgrade: () => {} }, - decorators: [ - (S) => ( -
    - -
    - ), - ], -}; -export default meta; -type Story = StoryObj; - -// Free, approaching the cap — warning banner + cap meter. -export const FreeApproachingCap: Story = { - args: { summary: buildBillingSummary("free") }, - globals: { tier: "free" }, -}; - -// Free, cap reached — danger banner, processing paused. -export const FreeCapReached: Story = { - args: { - summary: { - ...buildBillingSummary("free"), - docsThisPeriod: 500, - capReached: true, - }, - }, - globals: { tier: "free" }, -}; - -// Pro pay-as-you-go breakdown with metered overage. -export const Pro: Story = { - args: { summary: buildBillingSummary("pro") }, - globals: { tier: "pro" }, -}; - -// Enterprise committed-volume breakdown. -export const Enterprise: Story = { - args: { summary: buildBillingSummary("enterprise") }, - globals: { tier: "enterprise" }, -}; diff --git a/frontend/portal/src/components/usage/CurrentPlanCard.tsx b/frontend/portal/src/components/usage/CurrentPlanCard.tsx deleted file mode 100644 index 4dde3a06c1..0000000000 --- a/frontend/portal/src/components/usage/CurrentPlanCard.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Banner, - Button, - Card, - ProgressBar, - StatusBadge, -} from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import { OVERAGE_RATE, type BillingSummary } from "@portal/api/usage"; -import { USD } from "@portal/components/usage/format"; -import "@portal/views/Usage.css"; - -function BreakdownRow({ - label, - value, - emphasis, -}: { - label: string; - value: string; - emphasis?: boolean; -}) { - return ( -
    - {label} - {value} -
    - ); -} - -/** - * Current-plan summary card. The body adapts per tier: a cap meter + nudge on - * free, a metered pay-as-you-go breakdown on pro, a committed-volume breakdown - * on enterprise. - */ -export function CurrentPlanCard({ - summary, - onUpgrade, -}: { - summary: BillingSummary; - onUpgrade: () => void; -}) { - const { t } = useTranslation(); - const { tier } = useTier(); - const usedRatio = summary.docsThisPeriod / summary.includedDocs; - - return ( - -
    -
    - - {t("usage.currentPlan.eyebrow")} - -

    {summary.planName}

    -
    - - {tier === "free" - ? t("usage.currentPlan.badge.free") - : tier === "pro" - ? t("usage.currentPlan.badge.pro") - : t("usage.currentPlan.badge.enterprise")} - -
    - - {tier === "free" && ( - <> -
    -
    - - {summary.docsThisPeriod.toLocaleString()} /{" "} - {summary.includedDocs.toLocaleString()} docs - - - {Math.round(usedRatio * 100)}% - -
    - -
    - {summary.capReached ? ( - - {t("usage.currentPlan.free.capReached.body")} - - ) : ( - - {t("usage.currentPlan.free.approaching.body", { - pct: Math.round(usedRatio * 100), - })} - - )} - - )} - - {tier === "pro" && ( -
    - - - - -
    - )} - - {tier === "enterprise" && ( -
    - - - - -
    - )} - -
    - {tier !== "enterprise" ? ( - - ) : ( - - )} - {/* TODO(backend): GET /v1/billing/invoices?format=pdf — bundle + download invoice PDFs. */} - -
    -
    - ); -} diff --git a/frontend/portal/src/components/usage/PlanCard.stories.tsx b/frontend/portal/src/components/usage/PlanCard.stories.tsx deleted file mode 100644 index d27d94cc33..0000000000 --- a/frontend/portal/src/components/usage/PlanCard.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PlanCard } from "@portal/components/usage/PlanCard"; -import { PLAN_OPTIONS } from "@portal/mocks/usage"; - -const [free, pro, enterprise] = PLAN_OPTIONS; - -const meta: Meta = { - title: "Portal/Usage/PlanCard", - component: PlanCard, - args: { onSelect: () => {} }, - decorators: [ - (S) => ( -
    - -
    - ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Free: Story = { args: { plan: free, isCurrent: false } }; - -export const Pro: Story = { args: { plan: pro, isCurrent: false } }; - -export const Enterprise: Story = { - args: { plan: enterprise, isCurrent: false }, -}; - -// The active plan is outlined and its CTA disabled. -export const Current: Story = { args: { plan: pro, isCurrent: true } }; diff --git a/frontend/portal/src/components/usage/PlanCard.tsx b/frontend/portal/src/components/usage/PlanCard.tsx deleted file mode 100644 index 2e5d182a12..0000000000 --- a/frontend/portal/src/components/usage/PlanCard.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, StatusBadge } from "@shared/components"; -import type { PlanOption } from "@portal/api/usage"; -import "@portal/views/Usage.css"; - -/** A single plan in the catalogue grid; highlighted when it's the active plan. */ -export function PlanCard({ - plan, - isCurrent, - onSelect, -}: { - plan: PlanOption; - isCurrent: boolean; - onSelect: () => void; -}) { - const { t } = useTranslation(); - const accent = plan.tier === "enterprise" ? "purple" : "blue"; - return ( - -
    -

    {plan.name}

    - {isCurrent && ( - - {t("usage.planCard.current")} - - )} -
    -
    - {plan.price} - - {plan.priceCadence} - -
    -

    {plan.blurb}

    -
      - {plan.features.map((f) => ( -
    • - - ✓ - - {f} -
    • - ))} -
    - -
    - ); -} diff --git a/frontend/portal/src/components/usage/SpendCapControl.stories.tsx b/frontend/portal/src/components/usage/SpendCapControl.stories.tsx deleted file mode 100644 index 0a426166ea..0000000000 --- a/frontend/portal/src/components/usage/SpendCapControl.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SpendCapControl } from "@portal/components/usage/SpendCapControl"; -import { buildBillingSummary } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/SpendCapControl", - component: SpendCapControl, - decorators: [ - (S) => ( -
    - -
    - ), - ], -}; -export default meta; -type Story = StoryObj; - -// Free can't accrue spend — explanatory card, no slider. -export const Free: Story = { - args: { summary: buildBillingSummary("free") }, - globals: { tier: "free" }, -}; - -// Pro with a cap already set — interactive slider + projection meter. -export const ProCapEnabled: Story = { - args: { summary: buildBillingSummary("pro") }, - globals: { tier: "pro" }, -}; - -// Pro with no cap set — starts collapsed behind "Enable cap". -export const ProCapDisabled: Story = { - args: { summary: { ...buildBillingSummary("pro"), spendCap: null } }, - globals: { tier: "pro" }, -}; - -// Enterprise spend is contract-governed — read-only card. -export const Enterprise: Story = { - args: { summary: buildBillingSummary("enterprise") }, - globals: { tier: "enterprise" }, -}; diff --git a/frontend/portal/src/components/usage/SpendCapControl.tsx b/frontend/portal/src/components/usage/SpendCapControl.tsx deleted file mode 100644 index c82992d383..0000000000 --- a/frontend/portal/src/components/usage/SpendCapControl.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - Card, - ProgressBar, - Slider, - StatusBadge, -} from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import type { BillingSummary } from "@portal/api/usage"; -import { USD } from "@portal/components/usage/format"; -import "@portal/views/Usage.css"; - -/** - * Monthly spend-cap control. Only pay-as-you-go can accrue spend, so free and - * enterprise render explanatory cards instead of the interactive slider. - */ -export function SpendCapControl({ summary }: { summary: BillingSummary }) { - const { t } = useTranslation(); - const { tier } = useTier(); - const [enabled, setEnabled] = useState(summary.spendCap !== null); - const [cap, setCap] = useState(summary.spendCap ?? 1_000); - - if (tier === "free") { - return ( - -

    - {t("usage.spendCap.free.title")} -

    -

    - {t("usage.spendCap.free.description")} -

    -
    - ); - } - - if (tier === "enterprise") { - return ( - -

    - {t("usage.spendCap.enterprise.title")} -

    -

    - {t("usage.spendCap.enterprise.description")} -

    -
    - - {t("usage.spendCap.enterprise.badge")} - - - {t("usage.spendCap.enterprise.overage", { - rate: summary.overageRate.toFixed(3), - })} - -
    -
    - ); - } - - const projected = summary.costThisMonth; - const capRatio = enabled ? Math.min(projected / cap, 1) : 0; - - // TODO(backend): PUT /v1/billing/spend-cap { enabled, cap } — persist the cap - // so processing pauses server-side when projected spend reaches the limit. - return ( - -
    -
    -

    - {t("usage.spendCap.pro.title")} -

    -

    - {t("usage.spendCap.pro.subtitle")} -

    -
    - -
    - - {enabled && ( - <> -
    - USD.format(v)} - /> -
    -
    - - {t("usage.spendCap.pro.projected", { - projected: USD.format(projected), - cap: USD.format(cap), - })} - - - {Math.round(capRatio * 100)}% - -
    - - - )} -
    - ); -} diff --git a/frontend/portal/src/components/usage/UpgradeModal.stories.tsx b/frontend/portal/src/components/usage/UpgradeModal.stories.tsx deleted file mode 100644 index 511006d30a..0000000000 --- a/frontend/portal/src/components/usage/UpgradeModal.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { UpgradeModal } from "@portal/components/usage/UpgradeModal"; -import { PLAN_OPTIONS } from "@portal/mocks/usage"; - -const enterprisePlan = - PLAN_OPTIONS.find((p) => p.tier === "enterprise") ?? null; - -const meta: Meta = { - title: "Portal/Usage/UpgradeModal", - component: UpgradeModal, - args: { open: true, onClose: () => {}, target: null }, -}; -export default meta; -type Story = StoryObj; - -// Free user pushed to pay-as-you-go. -export const FromFree: Story = { args: { currentTier: "free" } }; - -// Pro user with no specific target — generic committed-pricing nudge. -export const FromPro: Story = { args: { currentTier: "pro" } }; - -// Pro user selecting the enterprise plan — sales-conversation copy. -export const ProToEnterprise: Story = { - args: { currentTier: "pro", target: enterprisePlan }, -}; - -// Enterprise user routed to their account team. -export const FromEnterprise: Story = { args: { currentTier: "enterprise" } }; diff --git a/frontend/portal/src/components/usage/UpgradeModal.tsx b/frontend/portal/src/components/usage/UpgradeModal.tsx deleted file mode 100644 index 2dc989d6c7..0000000000 --- a/frontend/portal/src/components/usage/UpgradeModal.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import { Button, Modal } from "@shared/components"; -import type { Tier } from "@portal/contexts/TierContext"; -import type { PlanOption } from "@portal/api/usage"; -import "@portal/views/Usage.css"; - -interface UpgradeCopy { - title: string; - subtitle: string; - body: string; - bullets: string[]; - cta: string; - ctaAccent: "blue" | "purple"; -} - -/** - * Modal copy is intent-driven, not just plan-driven: a free user who has hit - * the cap sees urgency; a pro user is nudged toward a committed plan; an - * enterprise user is routed to their account team for bespoke terms. - */ -function upgradeCopy( - t: TFunction, - currentTier: Tier, - target: PlanOption | null, -): UpgradeCopy { - // Cap-reached: free user pushed to pay-as-you-go. - if (currentTier === "free") { - return { - title: t("usage.upgrade.free.title"), - subtitle: t("usage.upgrade.free.subtitle"), - body: t("usage.upgrade.free.body"), - bullets: [ - t("usage.upgrade.free.bullets.0"), - t("usage.upgrade.free.bullets.1"), - t("usage.upgrade.free.bullets.2"), - t("usage.upgrade.free.bullets.3"), - ], - cta: t("usage.upgrade.free.cta"), - ctaAccent: "blue", - }; - } - - // Commit-recommend: pro user with overage nudged to a committed plan. - if (currentTier === "pro") { - if (target?.tier === "enterprise") { - return { - title: t("usage.upgrade.proToEnterprise.title"), - subtitle: t("usage.upgrade.proToEnterprise.subtitle"), - body: t("usage.upgrade.proToEnterprise.body"), - bullets: [ - t("usage.upgrade.proToEnterprise.bullets.0"), - t("usage.upgrade.proToEnterprise.bullets.1"), - t("usage.upgrade.proToEnterprise.bullets.2"), - t("usage.upgrade.proToEnterprise.bullets.3"), - ], - cta: t("usage.upgrade.proToEnterprise.cta"), - ctaAccent: "purple", - }; - } - return { - title: t("usage.upgrade.pro.title"), - subtitle: t("usage.upgrade.pro.subtitle"), - body: t("usage.upgrade.pro.body"), - bullets: [ - t("usage.upgrade.pro.bullets.0"), - t("usage.upgrade.pro.bullets.1"), - t("usage.upgrade.pro.bullets.2"), - ], - cta: t("usage.upgrade.pro.cta"), - ctaAccent: "purple", - }; - } - - // Bespoke-enterprise: route to account team. - return { - title: t("usage.upgrade.enterprise.title"), - subtitle: t("usage.upgrade.enterprise.subtitle"), - body: t("usage.upgrade.enterprise.body"), - bullets: [ - t("usage.upgrade.enterprise.bullets.0"), - t("usage.upgrade.enterprise.bullets.1"), - t("usage.upgrade.enterprise.bullets.2"), - ], - cta: t("usage.upgrade.enterprise.cta"), - ctaAccent: "purple", - }; -} - -/** Intent-aware plan-change / sales-conversation modal. */ -export function UpgradeModal({ - open, - onClose, - currentTier, - target, -}: { - open: boolean; - onClose: () => void; - currentTier: Tier; - target: PlanOption | null; -}) { - const { t } = useTranslation(); - const copy = upgradeCopy(t, currentTier, target); - return ( - - - {/* TODO(backend): POST /v1/billing/plan-change { tier } (or hand off to - sales) — for now the CTA just dismisses the modal. */} - - - } - > -

    {copy.body}

    -
      - {copy.bullets.map((b) => ( -
    • - - ✓ - - {b} -
    • - ))} -
    -
    - ); -} diff --git a/frontend/portal/src/components/usage/UsageChart.stories.tsx b/frontend/portal/src/components/usage/UsageChart.stories.tsx deleted file mode 100644 index 51327a0db4..0000000000 --- a/frontend/portal/src/components/usage/UsageChart.stories.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { UsageChart } from "@portal/components/usage/UsageChart"; -import { buildUsagePayload } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/UsageChart", - component: UsageChart, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
    - -
    - ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/billing/usage", async () => { - await delay("infinite"); - return HttpResponse.json(buildUsagePayload()); - }), - ], - }, - }, -}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/billing/usage", () => - HttpResponse.json({ points: [], priorTotal: 0 }), - ), - ], - }, - }, -}; diff --git a/frontend/portal/src/components/usage/UsageChart.tsx b/frontend/portal/src/components/usage/UsageChart.tsx deleted file mode 100644 index a705f08132..0000000000 --- a/frontend/portal/src/components/usage/UsageChart.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { EmptyState, Skeleton } from "@shared/components"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { fetchBillingUsage, type UsageSeriesResponse } from "@portal/api/usage"; -import { UsageAreaChart } from "@portal/components/UsageAreaChart"; -import "@portal/components/UsageAreaChart.css"; - -/** 30-day docs-processed area chart, with the period total and prior-period delta. */ -export function UsageChart() { - const { t } = useTranslation(); - const state = useAsync(() => fetchBillingUsage(), []); - const { data: usage } = state; - const { isLoading } = useSectionFlags(state); - - const docs30d = useMemo( - () => usage?.points.reduce((sum, p) => sum + p.value, 0) ?? 0, - [usage], - ); - const deltaPct = useMemo(() => { - if (!usage || usage.priorTotal <= 0) return undefined; - return (docs30d - usage.priorTotal) / usage.priorTotal; - }, [usage, docs30d]); - - if (isLoading) { - return ( -
    - - - -
    - ); - } - - if (!usage || usage.points.length === 0) { - return ( - - ); - } - - return ( - - ); -} diff --git a/frontend/portal/src/components/usage/format.ts b/frontend/portal/src/components/usage/format.ts deleted file mode 100644 index 25497473d0..0000000000 --- a/frontend/portal/src/components/usage/format.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Shared currency / date formatting for the Usage & Billing surface. */ - -export const USD = new Intl.NumberFormat(undefined, { - style: "currency", - currency: "USD", - maximumFractionDigits: 2, -}); - -export function formatBillingDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }); -} diff --git a/frontend/portal/src/contexts/AccountLinkContext.tsx b/frontend/portal/src/contexts/AccountLinkContext.tsx new file mode 100644 index 0000000000..d3f4bf3678 --- /dev/null +++ b/frontend/portal/src/contexts/AccountLinkContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { + useAccountLink, + type UseAccountLink, +} from "@portal/hooks/useAccountLink"; + +/** + * Single app-wide {@link useAccountLink} instance. The link flow is orchestrated + * in exactly one place so that: + * - status is fetched once on mount (not per consumer), and + * - the SSO-return effect fires once — two instances would both call + * {@link UseAccountLink.completeLink} on return and re-register the device + * credential, leaving a duplicate linked_instance row. + * + * Consumers (the top-level link modal host, the Settings account-link panel, + * the link card) read this shared instance instead of calling the hook again. + */ +const AccountLinkContext = createContext(null); + +export function AccountLinkProvider({ children }: { children: ReactNode }) { + const link = useAccountLink(); + return ( + + {children} + + ); +} + +export function useAccountLinkContext(): UseAccountLink { + const v = useContext(AccountLinkContext); + if (!v) { + throw new Error( + "useAccountLinkContext must be used inside ", + ); + } + return v; +} diff --git a/frontend/portal/src/contexts/LinkContext.test.tsx b/frontend/portal/src/contexts/LinkContext.test.tsx new file mode 100644 index 0000000000..be13ede65d --- /dev/null +++ b/frontend/portal/src/contexts/LinkContext.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { + deriveLinkState, + LINK_INFO, + LinkProvider, + useApplyLinkFacts, + useLink, +} from "@portal/contexts/LinkContext"; + +describe("deriveLinkState", () => { + it("maps raw facts to the three link states", () => { + expect(deriveLinkState(false, false)).toBe("unlinked"); + expect(deriveLinkState(false, true)).toBe("unlinked"); + expect(deriveLinkState(true, false)).toBe("linked-free"); + expect(deriveLinkState(true, true)).toBe("linked-subscribed"); + }); +}); + +describe("LINK_INFO", () => { + it("only unlocks features once linked", () => { + expect(LINK_INFO.unlinked.unlocked).toBe(false); + expect(LINK_INFO["linked-free"].unlocked).toBe(true); + expect(LINK_INFO["linked-subscribed"].unlocked).toBe(true); + }); +}); + +function Probe() { + const { linkState, isLinked, featuresUnlocked } = useLink(); + const apply = useApplyLinkFacts(); + return ( +
    + {linkState} + {String(isLinked)} + {String(featuresUnlocked)} + +
    + ); +} + +describe("LinkProvider", () => { + it("defaults to unlinked and locks features", () => { + render( + + + , + ); + expect(screen.getByTestId("state").textContent).toBe("unlinked"); + expect(screen.getByTestId("linked").textContent).toBe("false"); + expect(screen.getByTestId("unlocked").textContent).toBe("false"); + }); + + it("applies link facts to update the derived state", () => { + render( + + + , + ); + act(() => screen.getByText("subscribe").click()); + expect(screen.getByTestId("state").textContent).toBe("linked-subscribed"); + expect(screen.getByTestId("unlocked").textContent).toBe("true"); + }); + + it("throws when useLink is used outside the provider", () => { + function Bare() { + useLink(); + return null; + } + expect(() => render()).toThrow(/useLink must be used/); + }); +}); diff --git a/frontend/portal/src/contexts/LinkContext.tsx b/frontend/portal/src/contexts/LinkContext.tsx new file mode 100644 index 0000000000..daeb1cd71a --- /dev/null +++ b/frontend/portal/src/contexts/LinkContext.tsx @@ -0,0 +1,119 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +/** + * The "linked" dimension of the account-link surface (combined-billing "Mode A"), + * a sibling to TierContext. It answers one question the rest of the portal asks: + * has this self-hosted org linked its SaaS account, and if so, is it on the free + * grant or actively subscribed? + * + * - `unlinked` — no SaaS account linked. Billable features render a + * "link to unlock" affordance. + * - `linked-free` — linked, running on the one-time free grant (500 PDFs). + * - `linked-subscribed` — linked with a live PAYG subscription. + * + * The portal admin establishes the link by signing in to the SaaS Supabase + * project in-app (auth/saasSupabase.ts + the shared Supabase login) and + * registering the instance (api/link.ts); the subscribed-vs-free distinction + * comes from the wallet (api/billing.ts Wallet.status). + */ +export type LinkState = "unlinked" | "linked-free" | "linked-subscribed"; + +export interface LinkInfo { + /** i18n key for the badge label; resolve with `t()` at the call site. */ + labelKey: string; + /** Whether billable features are unlocked (any linked state). */ + unlocked: boolean; +} + +export const LINK_INFO: Record = { + unlinked: { labelKey: "accountLink.state.unlinked", unlocked: false }, + "linked-free": { labelKey: "accountLink.state.free", unlocked: true }, + "linked-subscribed": { + labelKey: "accountLink.state.subscribed", + unlocked: true, + }, +}; + +interface LinkContextValue { + linkState: LinkState; + setLinkState: (state: LinkState) => void; + /** True for any linked state — gates "link to unlock" prompts. */ + isLinked: boolean; + /** Convenience for `LINK_INFO[linkState].unlocked` — billable features usable. */ + featuresUnlocked: boolean; + /** + * Bumps whenever the browser's SaaS session changes (e.g. a re-sign-in after + * expiry). Attended SaaS reads (the wallet) key off this to refetch with the + * fresh token without re-establishing the instance link. + */ + saasSessionNonce: number; + markSaasSessionChanged: () => void; +} + +const LinkContext = createContext(null); + +export function LinkProvider({ + children, + initialState = "unlinked", +}: { + children: ReactNode; + initialState?: LinkState; +}) { + const [linkState, setLinkState] = useState(initialState); + const [saasSessionNonce, setSaasSessionNonce] = useState(0); + const markSaasSessionChanged = useCallback( + () => setSaasSessionNonce((n) => n + 1), + [], + ); + const value = useMemo(() => { + const unlocked = LINK_INFO[linkState].unlocked; + return { + linkState, + setLinkState, + isLinked: linkState !== "unlinked", + featuresUnlocked: unlocked, + saasSessionNonce, + markSaasSessionChanged, + }; + }, [linkState, saasSessionNonce, markSaasSessionChanged]); + return {children}; +} + +export function useLink(): LinkContextValue { + const v = useContext(LinkContext); + if (!v) throw new Error("useLink must be used inside "); + return v; +} + +/** + * Derives the linked state from raw facts: whether the org has linked its SaaS + * account and whether it carries a live subscription. Keeps the unlinked / + * linked-free / linked-subscribed mapping in one place. + */ +export function deriveLinkState( + linked: boolean, + subscribed: boolean, +): LinkState { + if (!linked) return "unlinked"; + return subscribed ? "linked-subscribed" : "linked-free"; +} + +/** Hook returning a setter that maps raw link/subscription facts to LinkState. */ +export function useApplyLinkFacts(): ( + linked: boolean, + subscribed: boolean, +) => void { + const { setLinkState } = useLink(); + return useCallback( + (linked: boolean, subscribed: boolean) => + setLinkState(deriveLinkState(linked, subscribed)), + [setLinkState], + ); +} diff --git a/frontend/portal/src/contexts/TierContext.tsx b/frontend/portal/src/contexts/TierContext.tsx index 4b69419a6c..5f83159758 100644 --- a/frontend/portal/src/contexts/TierContext.tsx +++ b/frontend/portal/src/contexts/TierContext.tsx @@ -1,10 +1,13 @@ import { createContext, useContext, + useEffect, useMemo, useState, type ReactNode, } from "react"; +import { readMocksPreference } from "@portal/mocks/preference"; +import { useLink, type LinkState } from "@portal/contexts/LinkContext"; export type Tier = "free" | "pro" | "enterprise"; @@ -14,18 +17,34 @@ export interface TierInfo { } export const TIER_INFO: Record = { - free: { label: "Free Plan", dotColor: "var(--color-text-4)" }, - pro: { label: "Pay-as-you-go", dotColor: "var(--color-blue)" }, - enterprise: { label: "Enterprise Plan", dotColor: "var(--color-purple)" }, + // Matches SaaS branding (editor/cloud Payg + PaygFree): the always-free + // manual-tools tier is "Editor plan"; the metered tier is "Processor plan". + free: { label: "Editor plan", dotColor: "var(--color-text-4)" }, + pro: { label: "Processor plan", dotColor: "var(--color-blue)" }, + enterprise: { label: "Enterprise plan", dotColor: "var(--color-purple)" }, }; interface TierContextValue { tier: Tier; + /** No-op when MSW mocks are off (tier is derived from real link state). */ setTier: (tier: Tier) => void; + /** True when the tier value is derived from the real wallet/link, not the dropdown. */ + isDerived: boolean; } const TierContext = createContext(null); +/** Maps the real link/subscription state onto the tier the rest of the portal reads. */ +function tierFromLinkState(linkState: LinkState): Tier { + switch (linkState) { + case "linked-subscribed": + return "pro"; + case "linked-free": + case "unlinked": + return "free"; + } +} + export function TierProvider({ children, initialTier = "pro", @@ -33,8 +52,33 @@ export function TierProvider({ children: ReactNode; initialTier?: Tier; }) { - const [tier, setTier] = useState(initialTier); - const value = useMemo(() => ({ tier, setTier }), [tier]); + // Mocks toggling reloads the page (see MocksToggle), so a single read at mount + // is correct — the preference can't change without us remounting. + const mocksOn = useMemo(() => readMocksPreference(), []); + const { linkState } = useLink(); + + const [mockTier, setMockTier] = useState(initialTier); + + // When mocks are off, mirror the real link state into the tier so any + // component still keyed on `tier` (sidebar plan badge, gated panels) stays + // consistent with the wallet. When mocks are on, the dropdown wins. + useEffect(() => { + if (!mocksOn) { + setMockTier(tierFromLinkState(linkState)); + } + }, [mocksOn, linkState]); + + const value = useMemo( + () => ({ + tier: mocksOn ? mockTier : tierFromLinkState(linkState), + // Setter is a no-op when mocks are off — UI controls can disable themselves + // via `isDerived`, but even if one slips through, it has no effect. + setTier: mocksOn ? setMockTier : () => {}, + isDerived: !mocksOn, + }), + [mocksOn, mockTier, linkState], + ); + return {children}; } diff --git a/frontend/portal/src/contexts/UIContext.tsx b/frontend/portal/src/contexts/UIContext.tsx index 8d0400e18d..ba2ccbd344 100644 --- a/frontend/portal/src/contexts/UIContext.tsx +++ b/frontend/portal/src/contexts/UIContext.tsx @@ -19,8 +19,29 @@ interface UIContextValue { /** Settings is a modal overlay, not a route. */ settingsOpen: boolean; - openSettings: () => void; + /** + * The section the Settings modal should land on when opened. `null` lets the + * modal pick its own default. Cleared back to `null` on close. + */ + settingsInitialSection: string | null; + openSettings: (section?: string) => void; closeSettings: () => void; + + /** + * The account-link login modal. A single top-level instance — never nested in + * another overlay. Opening it from within Settings closes Settings first (no + * modal-in-modal) and reopens Settings on the account-link section once the + * login modal closes, so the admin returns to where they were. + */ + linkModalOpen: boolean; + /** + * "link" registers this instance (the normal first-time flow); "reauth" only + * refreshes an expired SaaS session for attended reads — it must NOT re-register + * (that would mint a duplicate device credential). + */ + linkModalMode: "link" | "reauth"; + openLinkModal: (mode?: "link" | "reauth") => void; + closeLinkModal: () => void; } const UIContext = createContext(null); @@ -29,6 +50,16 @@ export function UIProvider({ children }: { children: ReactNode }) { const [searchOpen, setSearchOpen] = useState(false); const [assistantOpen, setAssistantOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsInitialSection, setSettingsInitialSection] = useState< + string | null + >(null); + const [linkModalOpen, setLinkModalOpen] = useState(false); + const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link"); + // When the link modal is opened from inside Settings, remember the section to + // restore so closing the modal returns the admin to where they were. + const [reopenSettingsAfterLink, setReopenSettingsAfterLink] = useState< + string | null + >(null); const value = useMemo( () => ({ @@ -43,10 +74,48 @@ export function UIProvider({ children }: { children: ReactNode }) { toggleAssistant: () => setAssistantOpen((o) => !o), settingsOpen, - openSettings: () => setSettingsOpen(true), - closeSettings: () => setSettingsOpen(false), + settingsInitialSection, + openSettings: (section?: string) => { + setSettingsInitialSection(section ?? null); + setSettingsOpen(true); + }, + closeSettings: () => { + setSettingsOpen(false); + setSettingsInitialSection(null); + }, + + linkModalOpen, + linkModalMode, + openLinkModal: (mode: "link" | "reauth" = "link") => { + setLinkModalMode(mode); + // Never stack on Settings: close it first, and remember to reopen it on + // the account-link section once the login modal closes. + if (settingsOpen) { + setReopenSettingsAfterLink("account-link"); + setSettingsOpen(false); + setSettingsInitialSection(null); + } + setLinkModalOpen(true); + }, + closeLinkModal: () => { + setLinkModalOpen(false); + setLinkModalMode("link"); + if (reopenSettingsAfterLink) { + setSettingsInitialSection(reopenSettingsAfterLink); + setSettingsOpen(true); + setReopenSettingsAfterLink(null); + } + }, }), - [searchOpen, assistantOpen, settingsOpen], + [ + searchOpen, + assistantOpen, + settingsOpen, + settingsInitialSection, + linkModalOpen, + linkModalMode, + reopenSettingsAfterLink, + ], ); return {children}; diff --git a/frontend/portal/src/hooks/useAccountLink.test.tsx b/frontend/portal/src/hooks/useAccountLink.test.tsx new file mode 100644 index 0000000000..b327322778 --- /dev/null +++ b/frontend/portal/src/hooks/useAccountLink.test.tsx @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; +import { LinkProvider } from "@portal/contexts/LinkContext"; + +/** + * The SSO-return path is mode-aware: a "reauth" return must only refresh the + * session, NOT re-register the instance (re-registering mints a duplicate device + * credential). This is the exact regression that slipped through once, so it gets + * a dedicated guard. + */ +const { linkInstance, fetchStatus, unlinkInstance, getSession } = vi.hoisted( + () => ({ + linkInstance: vi.fn(), + fetchStatus: vi.fn(), + unlinkInstance: vi.fn(), + getSession: vi.fn(), + }), +); + +vi.mock("@portal/api/link", () => ({ + linkInstance, + fetchStatus, + unlinkInstance, +})); +vi.mock("@portal/auth/saasSupabase", () => ({ + PENDING_LINK_KEY: "stirling_pending_link", + isSaasSupabaseConfigured: true, + SAAS_OAUTH_PROVIDERS: [], + ensureSaasSupabase: () => ({ auth: { getSession } }), +})); + +import { useAccountLink } from "@portal/hooks/useAccountLink"; +import { PENDING_LINK_KEY } from "@portal/auth/saasSupabase"; + +function Probe() { + useAccountLink(); + return null; +} + +const renderHook = () => + render( + + + , + ); + +beforeEach(() => { + linkInstance.mockReset().mockResolvedValue({ linked: true, name: null }); + fetchStatus.mockReset().mockResolvedValue({ linked: true, name: null }); + unlinkInstance.mockReset(); + getSession.mockReset().mockResolvedValue({ + data: { session: { access_token: "tok" } }, + }); + sessionStorage.clear(); +}); +afterEach(() => sessionStorage.clear()); + +describe("useAccountLink — SSO return", () => { + it("reauth mode refreshes the session WITHOUT re-registering", async () => { + sessionStorage.setItem(PENDING_LINK_KEY, "reauth"); + renderHook(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(linkInstance).not.toHaveBeenCalled(); + }); + + it("link mode registers the instance with the returned token", async () => { + sessionStorage.setItem(PENDING_LINK_KEY, "link"); + renderHook(); + await waitFor(() => expect(linkInstance).toHaveBeenCalledTimes(1)); + expect(linkInstance.mock.calls[0][0].supabaseJwt).toBe("tok"); + }); +}); diff --git a/frontend/portal/src/hooks/useAccountLink.ts b/frontend/portal/src/hooks/useAccountLink.ts new file mode 100644 index 0000000000..8c7158dd7b --- /dev/null +++ b/frontend/portal/src/hooks/useAccountLink.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from "react"; +import type { SupabaseLoginSession } from "@shared/auth/ui/useSupabaseLogin"; +import { + ensureSaasSupabase, + isSaasSupabaseConfigured, + PENDING_LINK_KEY, +} from "@portal/auth/saasSupabase"; +import { + fetchStatus, + linkInstance, + unlinkInstance, + type LinkStatus, +} from "@portal/api/link"; +import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; + +/** + * Orchestrates the account-link flow for THIS instance: + * + * 1. The admin signs in to their Stirling account IN-APP (LinkAccountModal → + * shared Supabase login), minting a short-term SaaS JWT. + * 2. {@link completeLink} POSTs that JWT to the LOCAL backend (api/link.ts), + * which registers with SaaS and stores the device secret server-side. + * 3. The resulting Linked / Not-linked status is read back. + * + * Email/password resolves inline (the modal calls completeLink). SSO redirects + * the browser to the provider and back; the returned session is finished here on + * mount (see the pending-link effect). The device secret is never received or + * rendered. Subscription state is resolved separately from the wallet, so a fresh + * link marks the org linked-free. + */ + +export type LinkPhase = "idle" | "linking" | "error"; + +export interface UseAccountLink { + /** Whether the SaaS Supabase project is configured (false → link UI shows a configure state). */ + loginConfigured: boolean; + /** Linked / Not-linked status for this instance; null while first loading. */ + status: LinkStatus | null; + phase: LinkPhase; + error: string | null; + /** Finish linking THIS instance with a SaaS session minted by the login modal. */ + completeLink: (session: SupabaseLoginSession, name?: string) => Promise; + /** Unlink this instance. */ + unlink: () => Promise; +} + +export function useAccountLink(): UseAccountLink { + const applyLinkFacts = useApplyLinkFacts(); + const { markSaasSessionChanged } = useLink(); + const [status, setStatus] = useState(null); + const [phase, setPhase] = useState("idle"); + const [error, setError] = useState(null); + + const completeLink = useCallback( + async (session: SupabaseLoginSession, name?: string) => { + setPhase("linking"); + setError(null); + try { + const next = await linkInstance({ + supabaseJwt: session.access_token, + name, + }); + setStatus(next); + setPhase("idle"); + if (next.linked) applyLinkFacts(true, false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setPhase("error"); + } + }, + [applyLinkFacts], + ); + + // Read the current link status on mount. + useEffect(() => { + let cancelled = false; + void fetchStatus() + .then((s) => { + if (!cancelled) { + setStatus(s); + // A linked instance is at least linked-free; subscription comes from the wallet. + if (s.linked) applyLinkFacts(true, false); + } + }) + .catch(() => { + // Status endpoint absent (flag off) / unreachable → leave status null, + // which renders as "Not linked". Don't surface an error or leak an + // unhandled rejection for the expected flag-off case. + if (!cancelled) setStatus({ linked: false, name: null }); + }); + return () => { + cancelled = true; + }; + }, [applyLinkFacts]); + + // SSO return: an SSO sign-in we kicked off has redirected back and the SaaS + // session is now in the shared Supabase client. The pending marker carries the + // mode: "reauth" only refreshes attended reads (the instance is already linked + // — re-registering would mint a duplicate credential); anything else links. + useEffect(() => { + const supabase = ensureSaasSupabase(); + const pending = sessionStorage.getItem(PENDING_LINK_KEY); + if (!supabase || pending === null) return; + let cancelled = false; + void supabase.auth.getSession().then(({ data }) => { + sessionStorage.removeItem(PENDING_LINK_KEY); + const token = data.session?.access_token; + if (!token || cancelled) return; + if (pending === "reauth") { + markSaasSessionChanged(); + } else { + void completeLink({ access_token: token }); + } + }); + return () => { + cancelled = true; + }; + }, [completeLink, markSaasSessionChanged]); + + const unlink = useCallback(async () => { + setPhase("linking"); + setError(null); + try { + await unlinkInstance(); + setStatus({ linked: false, name: null }); + setPhase("idle"); + applyLinkFacts(false, false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setPhase("error"); + } + }, [applyLinkFacts]); + + return { + loginConfigured: isSaasSupabaseConfigured, + status, + phase, + error, + completeLink, + unlink, + }; +} diff --git a/frontend/portal/src/hooks/useStripePortal.ts b/frontend/portal/src/hooks/useStripePortal.ts new file mode 100644 index 0000000000..c438dabcca --- /dev/null +++ b/frontend/portal/src/hooks/useStripePortal.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from "react"; +import type { Wallet } from "@portal/api/billing"; +import { createPortalSession } from "@portal/billing/stripe"; + +/** + * Opens the Stripe customer portal for the wallet's team in a new tab. Card, + * invoice, and cancellation changes all live in Stripe's hosted portal — both + * the header "Manage Payment" action and the payment-method card's "Update" + * button route through here. + */ +export function useStripePortal(wallet: Wallet | null) { + const [opening, setOpening] = useState(false); + const [error, setError] = useState(null); + + const open = useCallback(async () => { + const teamId = wallet?.teamId; + if (teamId == null) return; + setOpening(true); + setError(null); + try { + const url = await createPortalSession({ + teamId, + returnUrl: window.location.href, + }); + window.open(url, "_blank", "noopener,noreferrer"); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setOpening(false); + } + }, [wallet?.teamId]); + + return { open, opening, error }; +} diff --git a/frontend/portal/src/mocks/agents.ts b/frontend/portal/src/mocks/agents.ts index 60a10ebb74..e8523d11c7 100644 --- a/frontend/portal/src/mocks/agents.ts +++ b/frontend/portal/src/mocks/agents.ts @@ -6,7 +6,7 @@ * cases), tool-access governance, an eval / golden set, and version history. * * api/agents.ts imports the types; the MSW handlers serve this fixture data - * over the intercepted httpJson() calls. Components never reach into this + * over the intercepted apiClient.local.json() calls. Components never reach into this * module directly. Once a real backend exists the handlers stop being * registered and these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/mocks/docs.ts b/frontend/portal/src/mocks/docs.ts index 59e5e91d64..6aae3fe45b 100644 --- a/frontend/portal/src/mocks/docs.ts +++ b/frontend/portal/src/mocks/docs.ts @@ -1,7 +1,7 @@ /** * Developer Docs fixtures and the types api/docs.ts shares with them. * api/docs.ts imports the types; the MSW handlers in mocks/handlers/ serve the - * fixture data over the intercepted httpJson() calls. Components never reach + * fixture data over the intercepted apiClient.local.json() calls. Components never reach * into this module directly. * * Two payloads back the surface: diff --git a/frontend/portal/src/mocks/documents.ts b/frontend/portal/src/mocks/documents.ts index 49b524887c..6e1cc973e3 100644 --- a/frontend/portal/src/mocks/documents.ts +++ b/frontend/portal/src/mocks/documents.ts @@ -7,7 +7,7 @@ * shown directly or behind a zero-standing-access elevation request. * * api/documents.ts imports the types; the MSW handlers serve the fixture data - * over the intercepted httpJson() calls. Components never reach into this + * over the intercepted apiClient.local.json() calls. Components never reach into this * module directly. Once a real backend exists the handlers stop being * registered and these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/mocks/editorDeploy.ts b/frontend/portal/src/mocks/editorDeploy.ts index d4bbbac678..398ef69470 100644 --- a/frontend/portal/src/mocks/editorDeploy.ts +++ b/frontend/portal/src/mocks/editorDeploy.ts @@ -7,7 +7,7 @@ * instance, and the service credential / offline-activation lifecycle. * * api/editorDeploy.ts imports the types; the MSW handlers serve this fixture - * data over the intercepted httpJson() calls. Components never reach into this + * data over the intercepted apiClient.local.json() calls. Components never reach into this * module directly. Once a real backend exists the handlers stop being registered * and these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/mocks/handlers/index.ts b/frontend/portal/src/mocks/handlers/index.ts index 2d263cc4d0..130d1a7b35 100644 --- a/frontend/portal/src/mocks/handlers/index.ts +++ b/frontend/portal/src/mocks/handlers/index.ts @@ -7,7 +7,6 @@ import { searchHandlers } from "@portal/mocks/handlers/search"; import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines"; import { sourcesHandlers } from "@portal/mocks/handlers/sources"; import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure"; -import { usageHandlers } from "@portal/mocks/handlers/usage"; import { docsHandlers } from "@portal/mocks/handlers/docs"; import { settingsHandlers } from "@portal/mocks/handlers/settings"; import { usersHandlers } from "@portal/mocks/handlers/users"; @@ -16,6 +15,7 @@ import { policiesHandlers } from "@portal/mocks/handlers/policies"; import { documentsHandlers } from "@portal/mocks/handlers/documents"; import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents"; import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy"; +import { linkHandlers } from "@portal/mocks/handlers/link"; export const handlers = [ ...authHandlers, @@ -27,7 +27,6 @@ export const handlers = [ ...pipelinesHandlers, ...sourcesHandlers, ...infrastructureHandlers, - ...usageHandlers, ...docsHandlers, ...settingsHandlers, ...usersHandlers, @@ -36,6 +35,7 @@ export const handlers = [ ...documentsHandlers, ...sdkComponentsHandlers, ...editorDeployHandlers, + ...linkHandlers, ]; export { resetNotificationsStore } from "@portal/mocks/handlers/notifications"; diff --git a/frontend/portal/src/mocks/handlers/link.ts b/frontend/portal/src/mocks/handlers/link.ts new file mode 100644 index 0000000000..2be110a794 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/link.ts @@ -0,0 +1,65 @@ +import { http, HttpResponse, delay } from "msw"; +import { + getLocalStatus, + linkLocal, + listInstances, + revokeInstance, + unlinkLocal, + type LinkInstanceRequest, +} from "@portal/mocks/link"; + +/** + * Account-link MSW handlers. Two surfaces: + * + * - LOCAL backend (this instance): link / status / unlink. `link` mutates the + * in-memory store and flips local status so the surface behaves like a real + * backend within a session. The device secret stays server-side — never + * returned over the wire, matching the real contract. + * - SaaS backend (team-wide): instances / revoke. + * + * Mirrors the real AccountLinkController paths so MSW can be dropped with no code + * change. + */ +export const linkHandlers = [ + http.get("/api/v1/account-link/status", async () => { + await delay(120); + return HttpResponse.json(getLocalStatus()); + }), + + http.post("/api/v1/account-link/link", async ({ request }) => { + await delay(120); + let name: string | undefined; + try { + name = ((await request.json()) as LinkInstanceRequest)?.name; + } catch { + // empty body — name stays undefined + } + return HttpResponse.json(linkLocal(name), { status: 201 }); + }), + + http.post("/api/v1/account-link/unlink", async () => { + await delay(120); + // Clear local link state, then 204 (no body) to match the real backend. + unlinkLocal(); + return new HttpResponse(null, { status: 204 }); + }), + + // Team-wide list/revoke are SaaS-direct now (apiClient.saas calls the + // absolute VITE_SAAS_API_URL). Wildcard so the same handlers intercept both + // the relative pattern (legacy / direct-MSW usage) and any absolute SaaS + // base URL configured in dev/test. + http.get("*/api/v1/account-link/instances", async () => { + await delay(120); + return HttpResponse.json(listInstances()); + }), + + http.post( + "*/api/v1/account-link/instances/:instanceId/revoke", + async ({ params }) => { + await delay(120); + const ok = revokeInstance(Number(params.instanceId)); + if (!ok) return new HttpResponse(null, { status: 404 }); + return new HttpResponse(null, { status: 204 }); + }, + ), +]; diff --git a/frontend/portal/src/mocks/handlers/usage.ts b/frontend/portal/src/mocks/handlers/usage.ts deleted file mode 100644 index 82859349bc..0000000000 --- a/frontend/portal/src/mocks/handlers/usage.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { http, HttpResponse, delay } from "msw"; -import type { Tier } from "@portal/contexts/TierContext"; -import { - buildBillingHistory, - buildBillingSummary, - buildUsagePayload, - PLAN_OPTIONS, -} from "@portal/mocks/usage"; - -function tierFrom(request: Request): Tier { - const url = new URL(request.url); - return (url.searchParams.get("tier") ?? "pro") as Tier; -} - -export const usageHandlers = [ - http.get("/v1/billing/usage", async () => { - await delay(120); - return HttpResponse.json(buildUsagePayload()); - }), - - http.get("/v1/billing/summary", async ({ request }) => { - await delay(120); - return HttpResponse.json(buildBillingSummary(tierFrom(request))); - }), - - http.get("/v1/billing/plans", async () => { - await delay(120); - return HttpResponse.json(PLAN_OPTIONS); - }), - - http.get("/v1/billing/history", async ({ request }) => { - await delay(120); - return HttpResponse.json(buildBillingHistory(tierFrom(request))); - }), -]; diff --git a/frontend/portal/src/mocks/home.ts b/frontend/portal/src/mocks/home.ts index 229da46fa2..125a25d336 100644 --- a/frontend/portal/src/mocks/home.ts +++ b/frontend/portal/src/mocks/home.ts @@ -1,7 +1,7 @@ /** * Home dashboard fixtures and the types api/home.ts shares with them. * api/home.ts imports the types; the MSW handlers in mocks/handlers/ serve the - * fixture data over the intercepted httpJson() calls. Components never reach + * fixture data over the intercepted apiClient.local.json() calls. Components never reach * into this module directly. * * Once a real backend exists, the MSW handlers stop being registered and these diff --git a/frontend/portal/src/mocks/infrastructure.ts b/frontend/portal/src/mocks/infrastructure.ts index 98e06994a7..47c0c664ab 100644 --- a/frontend/portal/src/mocks/infrastructure.ts +++ b/frontend/portal/src/mocks/infrastructure.ts @@ -1,7 +1,7 @@ /** * Infrastructure surface fixtures and the types api/infrastructure.ts shares * with them. api/infrastructure.ts imports the types; the MSW handlers in - * mocks/handlers/ serve the fixture data over the intercepted httpJson() calls. + * mocks/handlers/ serve the fixture data over the intercepted apiClient.local.json() calls. * Components never reach into this module directly. * * Everything here is tier-scaled deterministically: free sees a single region diff --git a/frontend/portal/src/mocks/link.test.ts b/frontend/portal/src/mocks/link.test.ts new file mode 100644 index 0000000000..bd1cd505ef --- /dev/null +++ b/frontend/portal/src/mocks/link.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + getLocalStatus, + linkLocal, + listInstances, + resetLinkStore, + revokeInstance, + unlinkLocal, +} from "@portal/mocks/link"; + +describe("mocks/link store", () => { + beforeEach(() => resetLinkStore()); + + it("starts not-linked locally", () => { + expect(getLocalStatus().linked).toBe(false); + }); + + it("lists seed instances newest-first", () => { + const rows = listInstances(); + expect(rows.length).toBeGreaterThan(0); + for (let i = 1; i < rows.length; i++) { + expect(rows[i - 1].instanceId).toBeGreaterThan(rows[i].instanceId); + } + }); + + it("links locally and adds an active instance — without surfacing any secret", () => { + const before = listInstances().length; + const status = linkLocal("new-node"); + expect(status.linked).toBe(true); + expect(status.name).toBe("new-node"); + expect(status).not.toHaveProperty("deviceSecret"); + + const rows = listInstances(); + expect(rows.length).toBe(before + 1); + const added = rows.find((r) => r.name === "new-node"); + expect(added).toBeDefined(); + expect(added?.revoked).toBe(false); + expect(added?.lastSeenAt).toBeNull(); + }); + + it("links without a name as null", () => { + expect(linkLocal().name).toBeNull(); + }); + + it("unlinks locally", () => { + linkLocal("temp"); + expect(getLocalStatus().linked).toBe(true); + expect(unlinkLocal().linked).toBe(false); + }); + + it("revokes an instance and is idempotent", () => { + const id = listInstances().find((r) => !r.revoked)!.instanceId; + expect(revokeInstance(id)).toBe(true); + expect(listInstances().find((r) => r.instanceId === id)?.revoked).toBe( + true, + ); + // Idempotent — revoking again still returns true. + expect(revokeInstance(id)).toBe(true); + }); + + it("returns false revoking an unknown instance", () => { + expect(revokeInstance(999_999)).toBe(false); + }); + + it("resets to seed state", () => { + linkLocal("temp"); + const grown = listInstances().length; + resetLinkStore(); + expect(listInstances().length).toBeLessThan(grown); + expect(getLocalStatus().linked).toBe(false); + }); +}); diff --git a/frontend/portal/src/mocks/link.ts b/frontend/portal/src/mocks/link.ts new file mode 100644 index 0000000000..f540f85177 --- /dev/null +++ b/frontend/portal/src/mocks/link.ts @@ -0,0 +1,147 @@ +/** + * Account-link fixtures and the types api/link.ts shares with them. + * + * "Mode A" combined billing: a self-hosted instance links the org's SaaS account + * so its unattended calls bill against the org wallet. Two surfaces: + * + * - THIS instance: the local backend (`POST /api/v1/account-link/link`, + * `GET /status`, `POST /unlink`). Linking hands the local backend the admin's + * SaaS JWT; it registers with SaaS and stores the device secret SERVER-SIDE. + * The portal only ever sees a Linked / Not-linked status — never the secret. + * - TEAM-WIDE management: the SaaS backend (`GET /instances`, + * `POST /instances/{id}/revoke`), called with the admin's JWT. + * + * api/link.ts imports the types; the MSW handlers in mocks/handlers/link.ts serve + * this fixture data over the intercepted apiClient.local.json() calls. Components never reach + * into this module directly. Once the real backend is wired the handlers stop + * being registered and these fixtures can be deleted (or kept as test seeds). + */ + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Local backend — link / status / unlink (this instance) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */ +export interface LinkInstanceRequest { + /** Admin's SaaS session JWT, obtained via the hosted-login popup. */ + supabaseJwt: string; + /** Optional label for this instance. */ + name?: string; +} + +/** Link status for this instance (GET /api/v1/account-link/status). */ +export interface LinkStatus { + linked: boolean; + /** Display name the local backend stored at link time; null when unset. */ + name: string | null; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* SaaS backend — team-wide instance management */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** A linked instance row (GET /api/v1/account-link/instances). */ +export interface LinkedInstanceRow { + instanceId: number; + deviceId: string; + name: string | null; + /** ISO timestamp the instance was registered. */ + createdAt: string | null; + /** ISO timestamp the instance last presented its credential; null if never. */ + lastSeenAt: string | null; + revoked: boolean; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Mock store — link/unlink/revoke mutate this so the surface feels live */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function seedInstances(): LinkedInstanceRow[] { + return [ + { + instanceId: 1001, + deviceId: "8f2c1d4a-6b3e-4a9f-9c10-2d5e7f1a0b34", + name: "prod-eu-gateway", + createdAt: daysAgo(28), + lastSeenAt: minutesAgo(3), + revoked: false, + }, + { + instanceId: 1002, + deviceId: "1a9b8c7d-2e3f-4051-8a6b-9c0d1e2f3a4b", + name: "staging-docker", + createdAt: daysAgo(11), + lastSeenAt: minutesAgo(140), + revoked: false, + }, + { + instanceId: 1003, + deviceId: "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", + name: "retired-poc", + createdAt: daysAgo(96), + lastSeenAt: daysAgo(40), + revoked: true, + }, + ]; +} + +let store: LinkedInstanceRow[] = seedInstances(); +let nextId = 1004; +let localStatus: LinkStatus = { linked: false, name: null }; + +/** Resets the mock store + local link status to seed state (Storybook / tests). */ +export function resetLinkStore(): void { + store = seedInstances(); + nextId = 1004; + localStatus = { linked: false, name: null }; +} + +/** Current local link status for this instance. */ +export function getLocalStatus(): LinkStatus { + return { ...localStatus }; +} + +/** + * Links this instance: the local backend would register with SaaS and persist + * the device secret itself. The mock just appends a row and flips local status — + * no secret is ever surfaced. + */ +export function linkLocal(name?: string): LinkStatus { + const instanceId = nextId++; + store.push({ + instanceId, + deviceId: crypto.randomUUID(), + name: name ?? null, + createdAt: new Date().toISOString(), + lastSeenAt: null, + revoked: false, + }); + localStatus = { linked: true, name: name ?? null }; + return getLocalStatus(); +} + +/** Unlinks this instance locally. */ +export function unlinkLocal(): LinkStatus { + localStatus = { linked: false, name: null }; + return getLocalStatus(); +} + +/** All instances for the org, newest first (includes revoked). */ +export function listInstances(): LinkedInstanceRow[] { + return [...store].sort((a, b) => b.instanceId - a.instanceId); +} + +/** Revokes an instance by id. Returns false if not found. Idempotent. */ +export function revokeInstance(instanceId: number): boolean { + const row = store.find((i) => i.instanceId === instanceId); + if (!row) return false; + row.revoked = true; + return true; +} + +function daysAgo(n: number): string { + return new Date(Date.now() - n * 86_400_000).toISOString(); +} +function minutesAgo(n: number): string { + return new Date(Date.now() - n * 60_000).toISOString(); +} diff --git a/frontend/portal/src/mocks/pipelines.ts b/frontend/portal/src/mocks/pipelines.ts index 3183581f53..d39e7b7083 100644 --- a/frontend/portal/src/mocks/pipelines.ts +++ b/frontend/portal/src/mocks/pipelines.ts @@ -1,7 +1,7 @@ /** * Pipelines fixtures and the types api/pipelines.ts shares with them. * api/pipelines.ts imports the types; the MSW handlers in mocks/handlers/ - * serve the fixture data over the intercepted httpJson() calls. Components + * serve the fixture data over the intercepted apiClient.local.json() calls. Components * never reach into this module directly. * * Fixtures are tier-shaped: diff --git a/frontend/portal/src/mocks/policies.ts b/frontend/portal/src/mocks/policies.ts index ccf28345ec..76339e5acd 100644 --- a/frontend/portal/src/mocks/policies.ts +++ b/frontend/portal/src/mocks/policies.ts @@ -16,7 +16,7 @@ * ReactNode icons replaced by string icon keys (the portal renders its own). * * api/policies.ts re-exports these types; the MSW handlers serve the fixture - * data over intercepted httpJson() calls. Components never reach in here. + * data over intercepted apiClient.local.json() calls. Components never reach in here. */ /* ──────────────────────────────────────────────────────────────────────── */ diff --git a/frontend/portal/src/mocks/sdkComponents.ts b/frontend/portal/src/mocks/sdkComponents.ts index b5c5d0c060..aed16d2028 100644 --- a/frontend/portal/src/mocks/sdkComponents.ts +++ b/frontend/portal/src/mocks/sdkComponents.ts @@ -8,7 +8,7 @@ * price, an install/usage snippet, and its key props. * * api/sdkComponents.ts imports the types; the MSW handlers serve the fixture - * data over the intercepted httpJson() calls. Components never reach into this + * data over the intercepted apiClient.local.json() calls. Components never reach into this * module directly. Once a real backend exists the handlers stop being * registered and these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/mocks/settings.ts b/frontend/portal/src/mocks/settings.ts index be98a3fb07..99bcf313cb 100644 --- a/frontend/portal/src/mocks/settings.ts +++ b/frontend/portal/src/mocks/settings.ts @@ -1,7 +1,7 @@ /** * Account-settings fixtures and the types api/settings.ts shares with them. * api/settings.ts imports the types; the MSW handlers in mocks/handlers/ serve - * the fixture data over the intercepted httpJson() call. Components never reach + * the fixture data over the intercepted apiClient.local.json() call. Components never reach * into this module directly. * * The shape is tier-aware: the workspace plan label, available regions, and @@ -97,9 +97,9 @@ const REGIONS: RegionOption[] = [ ]; const PLAN_LABEL: Record = { - free: "Free Plan", - pro: "Pay-as-you-go", - enterprise: "Enterprise Plan", + free: "Editor plan", + pro: "Processor plan", + enterprise: "Enterprise plan", }; const SEATS: Record = { diff --git a/frontend/portal/src/mocks/usage.ts b/frontend/portal/src/mocks/usage.ts deleted file mode 100644 index 8fb6068549..0000000000 --- a/frontend/portal/src/mocks/usage.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Usage & Billing fixtures and the types api/usage.ts shares with them. - * api/usage.ts imports the types; the MSW handlers in mocks/handlers/usage.ts - * serve this fixture data over the intercepted httpJson() calls. Components - * never reach into this module directly. - * - * Once a real billing backend exists, the MSW handlers stop being registered - * and these fixtures can be deleted (or kept as test seeds). - */ - -import type { Tier } from "@portal/contexts/TierContext"; -import { - buildUsageSeries, - buildUsageSeriesResponse, - type UsageSeriesResponse, -} from "@portal/mocks/home"; - -export type { UsagePoint, UsageSeriesResponse } from "@portal/mocks/home"; - -/** Per-doc rate charged once the free cap is exceeded (pay-as-you-go). */ -export const OVERAGE_RATE = 0.05; - -/** Documents included before overage / cap kicks in, per tier. */ -export const TIER_DOC_CAP: Record = { - free: 500, - pro: 25_000, - enterprise: 2_000_000, -}; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Billing summary (KPI strip + plan card) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface BillingSummary { - tier: Tier; - /** Human plan name shown on the current-plan card. */ - planName: string; - /** Docs processed in the current billing period. */ - docsThisPeriod: number; - /** Included docs before overage / cap. */ - includedDocs: number; - /** Cost accrued this month, in USD. */ - costThisMonth: number; - /** Overage docs past the included cap (0 when under). */ - overageDocs: number; - /** Overage cost this month, in USD. */ - overageCost: number; - /** Per-doc overage rate, in USD. */ - overageRate: number; - /** Fixed monthly platform fee, in USD (0 for free / usage-only). */ - monthlyFee: number; - /** ISO date the next invoice closes. */ - nextBillingDate: string; - /** Optional user-set hard spend cap for the month, in USD. */ - spendCap: number | null; - /** True when the free plan's doc cap has been reached. */ - capReached: boolean; -} - -/** Builds a deterministic billing summary for a tier from the usage series. */ -export function buildBillingSummary(tier: Tier): BillingSummary { - const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); - const included = TIER_DOC_CAP[tier]; - const nextBillingDate = nextMonthFirst(); - - if (tier === "free") { - // Free is gated, not metered — show progress toward the hard cap. 463/500 - // sits in the "approaching cap" band that drives the upgrade nudge. - const docs = 463; - return { - tier, - planName: "Free", - docsThisPeriod: docs, - includedDocs: included, - costThisMonth: 0, - overageDocs: 0, - overageCost: 0, - overageRate: OVERAGE_RATE, - monthlyFee: 0, - nextBillingDate, - spendCap: null, - capReached: docs >= included, - }; - } - - if (tier === "enterprise") { - // Committed-volume contract — usage sits comfortably inside the commit. - return { - tier, - planName: "Enterprise (committed)", - docsThisPeriod: docs30d, - includedDocs: included, - costThisMonth: 18_000, - overageDocs: 0, - overageCost: 0, - overageRate: 0.018, - monthlyFee: 18_000, - nextBillingDate, - spendCap: null, - capReached: false, - }; - } - - // Pro: pay-as-you-go with a small platform fee + metered overage. - const monthlyFee = 49; - const overageDocs = Math.max(0, docs30d - included); - const overageCost = +(overageDocs * OVERAGE_RATE).toFixed(2); - return { - tier, - planName: "Pay-as-you-go", - docsThisPeriod: docs30d, - includedDocs: included, - costThisMonth: +(monthlyFee + overageCost).toFixed(2), - overageDocs, - overageCost, - overageRate: OVERAGE_RATE, - monthlyFee, - nextBillingDate, - spendCap: 2_500, - capReached: false, - }; -} - -/** First day of next month, ISO (YYYY-MM-DD) — the next invoice close date. */ -function nextMonthFirst(): string { - const now = new Date(); - const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); - return next.toISOString().slice(0, 10); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Plan catalogue (current + available plan cards) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface PlanOption { - tier: Tier; - name: string; - /** Headline price line, e.g. "$0" or "$0.05 / doc". */ - price: string; - /** Cadence sub-line under the price, e.g. "forever" or "+ $49/mo platform". */ - priceCadence: string; - /** One-line positioning blurb. */ - blurb: string; - /** Bullet feature list. */ - features: string[]; -} - -export const PLAN_OPTIONS: PlanOption[] = [ - { - tier: "free", - name: "Free", - price: "$0", - priceCadence: "forever", - blurb: "Kick the tyres on a single project.", - features: [ - "500 docs / month", - "All single operations", - "1 pipeline · 1 agent", - "Community support", - ], - }, - { - tier: "pro", - name: "Pay-as-you-go", - price: "$0.05", - priceCadence: "per doc · + $49/mo platform", - blurb: "Scale with usage, only pay for what you process.", - features: [ - "25,000 docs included", - "Unlimited pipelines & agents", - "Overage at $0.05 / doc", - "Email support · 99.9% SLA", - ], - }, - { - tier: "enterprise", - name: "Enterprise", - price: "Custom", - priceCadence: "committed annual volume", - blurb: "Committed volume, bespoke terms, dedicated regions.", - features: [ - "Committed-volume pricing", - "Dedicated & on-prem regions", - "SSO · audit log export · DPA", - "Named CSM · 99.99% SLA", - ], - }, -]; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Billing history table */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type InvoiceStatus = "paid" | "due" | "pending" | "refunded"; - -export interface BillingHistoryRow { - id: string; - /** ISO date the line item posted. */ - date: string; - description: string; - /** Docs attributed to the line item (0 for flat fees / credits). */ - docs: number; - /** Amount in USD; negative for credits / refunds. */ - amount: number; - status: InvoiceStatus; -} - -export function buildBillingHistory(tier: Tier): BillingHistoryRow[] { - if (tier === "free") { - // Free has no charges — just the running tally line. - return [ - { - id: "bh-free-1", - date: monthsAgo(0), - description: "Free plan usage · 463 / 500 docs", - docs: 463, - amount: 0, - status: "pending", - }, - { - id: "bh-free-2", - date: monthsAgo(1), - description: "Free plan usage · 500 / 500 docs (capped)", - docs: 500, - amount: 0, - status: "paid", - }, - ]; - } - - if (tier === "enterprise") { - return [ - { - id: "bh-ent-1", - date: monthsAgo(0), - description: "Committed volume · annual contract (monthly draw)", - docs: 1_240_511, - amount: 18_000, - status: "pending", - }, - { - id: "bh-ent-2", - date: monthsAgo(1), - description: "Committed volume · annual contract (monthly draw)", - docs: 1_188_204, - amount: 18_000, - status: "paid", - }, - { - id: "bh-ent-3", - date: monthsAgo(1), - description: "Dedicated region · ap-southeast-1 provisioning", - docs: 0, - amount: 4_500, - status: "paid", - }, - { - id: "bh-ent-4", - date: monthsAgo(2), - description: "Committed volume · annual contract (monthly draw)", - docs: 1_092_876, - amount: 18_000, - status: "paid", - }, - { - id: "bh-ent-5", - date: monthsAgo(3), - description: "Overage credit · region migration goodwill", - docs: 0, - amount: -1_200, - status: "refunded", - }, - ]; - } - - // Pro: platform fee + metered overage each cycle. - const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); - const overage = Math.max(0, docs30d - TIER_DOC_CAP.pro); - return [ - { - id: "bh-pro-1", - date: monthsAgo(0), - description: "Platform fee · current cycle", - docs: 0, - amount: 49, - status: "due", - }, - { - id: "bh-pro-2", - date: monthsAgo(0), - description: `Document overage · ${overage.toLocaleString()} docs @ $0.05`, - docs: overage, - amount: +(overage * OVERAGE_RATE).toFixed(2), - status: "due", - }, - { - id: "bh-pro-3", - date: monthsAgo(1), - description: "Platform fee · last cycle", - docs: 0, - amount: 49, - status: "paid", - }, - { - id: "bh-pro-4", - date: monthsAgo(1), - description: "Document overage · 31,402 docs @ $0.05", - docs: 31_402, - amount: 1_570.1, - status: "paid", - }, - { - id: "bh-pro-5", - date: monthsAgo(2), - description: "Platform fee · prior cycle", - docs: 0, - amount: 49, - status: "paid", - }, - { - id: "bh-pro-6", - date: monthsAgo(2), - description: "Document overage · 18,945 docs @ $0.05", - docs: 18_945, - amount: 947.25, - status: "paid", - }, - ]; -} - -/** N whole months back from today, on the 1st, ISO (YYYY-MM-DD). */ -function monthsAgo(n: number): string { - const now = new Date(); - const d = new Date(now.getFullYear(), now.getMonth() - n, 1); - return d.toISOString().slice(0, 10); -} - -/** Full usage payload for the 30-day chart — the same series Home charts. */ -export function buildUsagePayload(): UsageSeriesResponse { - return buildUsageSeriesResponse(); -} diff --git a/frontend/portal/src/mocks/users.ts b/frontend/portal/src/mocks/users.ts index 2c42cc04c7..136a9b07bb 100644 --- a/frontend/portal/src/mocks/users.ts +++ b/frontend/portal/src/mocks/users.ts @@ -7,7 +7,7 @@ * tier-scoped access controls (seat limits, MFA, sessions, SSO/SCIM). * * api/users.ts imports the types; the MSW handlers serve the fixture data over - * the intercepted httpJson() calls. Components never reach into this module + * the intercepted apiClient.local.json() calls. Components never reach into this module * directly. Once a real backend exists the handlers stop being registered and * these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/views/AccountLink.css b/frontend/portal/src/views/AccountLink.css new file mode 100644 index 0000000000..07b49a03f1 --- /dev/null +++ b/frontend/portal/src/views/AccountLink.css @@ -0,0 +1,125 @@ +.portal-link { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 72rem; + margin: 0 auto; +} + +/* Header */ +.portal-link__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.portal-link__page-title { + margin: 0; + font-size: 1.375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-link__page-sub { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--color-text-4); + max-width: 44rem; +} + +/* Link account card */ +.portal-link__card { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-link__card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.portal-link__eyebrow { + display: block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-5); +} + +.portal-link__title { + margin: 0.125rem 0 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-link__form, +.portal-link__register { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-link__form-actions, +.portal-link__actions { + display: flex; + align-items: center; + gap: 0.625rem; + flex-wrap: wrap; +} + +/* Instances section */ +.portal-link__instances { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-link__section-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-link__section-sub { + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: var(--color-text-4); + max-width: 44rem; +} + +.portal-link__skeleton { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Table cells */ +.portal-link__cell-stack { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.portal-link__cell-strong { + font-weight: 600; + color: var(--color-text-1); +} + +.portal-link__device-id { + font-size: 0.6875rem; + color: var(--color-text-5); + font-variant-numeric: tabular-nums; +} + +.portal-link__muted { + color: var(--color-text-4); + font-size: 0.8125rem; +} diff --git a/frontend/portal/src/views/Usage.css b/frontend/portal/src/views/Usage.css index bf39aeb105..c03e790680 100644 --- a/frontend/portal/src/views/Usage.css +++ b/frontend/portal/src/views/Usage.css @@ -1,18 +1,34 @@ .portal-usage { display: flex; flex-direction: column; - gap: 1.25rem; - padding: 1.5rem; - max-width: 84rem; - margin: 0 auto; } -/* Header */ +/* Sticky page header: a full-bleed bar (sidebar tint + bottom border). The + scroll container is .portal-shell__view (overflow-y:auto), whose top already + sits just below the global .portal-header — so this pins at top:0 of the view, + flush under the global bar. */ .portal-usage__header { + position: sticky; + top: 0; + z-index: 5; + background: var(--color-sidebar-bg); + border-bottom: 1px solid var(--color-border); +} +.portal-usage__header-inner { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 1rem; + padding: 1rem 1.5rem; +} + +/* Body is full-bleed (just padding) — the billing surface runs edge-to-edge to + match marketing, same as the free view. */ +.portal-usage__body { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; } .portal-usage__title { @@ -90,6 +106,48 @@ color: var(--color-text-1); } +/* Wallet contract card */ +.portal-usage__wallet { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-usage__wallet-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.portal-usage__wallet-badges { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Wallet hero metric — mirrors the SaaS plan-card price block */ +.portal-usage__wallet-hero { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portal-usage__wallet-hero-value { + font-size: 2.25rem; + font-weight: 600; + line-height: 1; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} + +.portal-usage__wallet-hero-unit { + font-size: 0.875rem; + color: var(--color-text-4); +} + /* Cap progress (free) */ .portal-usage__cap { display: flex; diff --git a/frontend/portal/src/views/Usage.tsx b/frontend/portal/src/views/Usage.tsx index 149b3872ae..172b88c3cf 100644 --- a/frontend/portal/src/views/Usage.tsx +++ b/frontend/portal/src/views/Usage.tsx @@ -1,105 +1,211 @@ -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Card, Skeleton, StatusBadge } from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync } from "@portal/hooks/useAsync"; +import { Banner, Button, Skeleton } from "@shared/components"; +import { useLink } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { fetchWallet, type Wallet } from "@portal/api/billing"; +import { useStripePortal } from "@portal/hooks/useStripePortal"; +import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import { FreePlanView } from "@portal/components/billing/FreePlanView"; +import { SubscribedPlanView } from "@portal/components/billing/SubscribedPlanView"; import { - fetchBillingSummary, - fetchPlanOptions, - type BillingSummary, - type PlanOption, -} from "@portal/api/usage"; -import { UsageChart } from "@portal/components/usage/UsageChart"; -import { BillingKpiStrip } from "@portal/components/usage/BillingKpiStrip"; -import { CurrentPlanCard } from "@portal/components/usage/CurrentPlanCard"; -import { SpendCapControl } from "@portal/components/usage/SpendCapControl"; -import { AvailablePlans } from "@portal/components/usage/AvailablePlans"; -import { BillingHistoryTable } from "@portal/components/usage/BillingHistoryTable"; -import { UpgradeModal } from "@portal/components/usage/UpgradeModal"; + HttpError, + SaasNotLinkedError, + SaasUnconfiguredError, +} from "@portal/api/http"; import "@portal/views/Usage.css"; +import "@portal/components/billing/billing.css"; +/** + * Billing & usage page. State-driven by the link/subscription dimension — + * NOT by the legacy {@code tier} prop: + * + * unlinked → LinkAccountPrompt + * linked-free → FreePlanView (free meter + PAYG explainer) + * linked-subscribed → SubscribedPlanView (period meter, cap, members, + * invoices, Stripe portal) + * + * Wallet comes from {@code GET /api/v1/payg/wallet} (apiClient.saas). After a + * subscription flip via Stripe checkout / cancel via the portal, the + * onWalletChange refresh re-reads and the view re-dispatches on the new + * status. + */ export function Usage() { const { t } = useTranslation(); - const { tier } = useTier(); - const [modalOpen, setModalOpen] = useState(false); - const [modalTarget, setModalTarget] = useState(null); + const { isLinked, setLinkState, saasSessionNonce } = useLink(); + const { openLinkModal } = useUI(); + const [wallet, setWallet] = useState(null); + const [loading, setLoading] = useState(isLinked); + const [error, setError] = useState(null); + // The instance is linked but the browser's SaaS session has lapsed — needs a + // re-sign-in, NOT a re-link. + const [needsReauth, setNeedsReauth] = useState(false); + // Briefly polling the wallet after a successful checkout until the webhook flips + // it to subscribed. + const [finalizing, setFinalizing] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + // Stripe customer portal — the subscribed header's "Manage Payment" action. + const portal = useStripePortal(wallet); + // Guards the post-checkout poll loop from setState after unmount. + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); - const summaryState = useAsync( - () => fetchBillingSummary(tier), - [tier], - ); - const summary = summaryState.loading ? null : summaryState.data; + useEffect(() => { + // Only fetch the wallet when the instance is linked. Unlinked → render the + // link prompt; no SaaS call needed. + if (!isLinked) { + setWallet(null); + setLoading(false); + setError(null); + setNeedsReauth(false); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + setNeedsReauth(false); + fetchWallet() + .then((w) => { + if (cancelled) return; + setWallet(w); + // Derive the linked-free / linked-subscribed dimension from the live + // wallet. Only refines a `linked-*` state; never flips unlinked → linked. + setLinkState( + w.status === "subscribed" ? "linked-subscribed" : "linked-free", + ); + }) + .catch((e) => { + if (cancelled) return; + if (e instanceof SaasNotLinkedError) { + // Reached only when the instance IS linked (we don't fetch otherwise), + // so this means the attended SaaS session expired — prompt re-sign-in. + setNeedsReauth(true); + } else if (e instanceof SaasUnconfiguredError) { + setError(e.message); + } else if (e instanceof HttpError) { + setError( + t("usage.error.walletUnavailable", { + status: e.status, + statusText: e.statusText, + }), + ); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isLinked, refreshKey, saasSessionNonce, setLinkState]); - const plansState = useAsync(() => fetchPlanOptions(), []); - const { data: plans } = plansState; + const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); - function openUpgrade(target: PlanOption | null) { - setModalTarget(target); - setModalOpen(true); - } + const confirmSubscription = useCallback(async () => { + // Stripe's onComplete fires before the subscription webhook lands, so poll the + // wallet briefly until it flips to subscribed rather than dropping the + // just-paid admin back on the free CTA. + setFinalizing(true); + for (let i = 0; i < 10; i++) { + try { + const w = await fetchWallet(); + if (!mounted.current) return; + if (w.status === "subscribed") { + setWallet(w); + setLinkState("linked-subscribed"); + setFinalizing(false); + return; + } + } catch { + // Transient read failure — keep polling. + } + await new Promise((r) => setTimeout(r, 2000)); + if (!mounted.current) return; + } + // Webhook still hasn't landed after ~20s: stop blocking and refresh. The page + // self-heals on the next load once provisioning completes. + setFinalizing(false); + setRefreshKey((k) => k + 1); + }, [setLinkState]); return ( -
    +
    -
    -

    {t("usage.title")}

    -

    {t("usage.subtitle")}

    +
    +
    +

    {t("usage.title")}

    +

    {t("usage.subtitle")}

    +
    + {wallet?.status === "subscribed" && ( + + )}
    - - {summary?.planName ?? "—"} -
    - +
    + {!isLinked && } - - -
    - {summary ? ( - openUpgrade(null)} - /> - ) : ( - - - - + {isLinked && loading && ( +
    + + +
    )} - {summary ? ( - - ) : ( - - - - + + {isLinked && finalizing && ( + + {t("usage.finalizing.body")} + + )} + + {isLinked && needsReauth && ( + openLinkModal("reauth")}> + {t("usage.sessionExpired.action")} + + } + > + {t("usage.sessionExpired.body")} + + )} + + {isLinked && error && ( + + {error} + + )} + + {isLinked && portal.error && ( + + {portal.error} + + )} + + {isLinked && !finalizing && wallet && wallet.status === "free" && ( + + )} + + {isLinked && wallet && wallet.status === "subscribed" && ( + )}
    - - {plans && plans.length > 0 && ( - // Any plan selection routes through the intent-aware upgrade modal; the - // target plan drives whether the copy is an upgrade pitch or a - // downgrade / sales conversation. - - )} - - - - setModalOpen(false)} - currentTier={tier} - target={modalTarget} - />
    ); } diff --git a/frontend/portal/src/views/Users.tsx b/frontend/portal/src/views/Users.tsx index 5e985fdc01..c7ffac50df 100644 --- a/frontend/portal/src/views/Users.tsx +++ b/frontend/portal/src/views/Users.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, EmptyState, Skeleton } from "@shared/components"; import { useTier } from "@portal/contexts/TierContext"; @@ -25,6 +26,17 @@ export function Users() { const [inviteOpen, setInviteOpen] = useState(false); + // Deep-link from elsewhere (e.g. billing's "Invite teammates"): ?invite opens + // the modal, then the param is cleared so a refresh/back doesn't re-open it. + const [searchParams, setSearchParams] = useSearchParams(); + useEffect(() => { + if (searchParams.get("invite") === null) return; + setInviteOpen(true); + const next = new URLSearchParams(searchParams); + next.delete("invite"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + const members = data?.members ?? []; // Row actions are non-functional shells until the backend exists; each logs diff --git a/frontend/portal/src/vite-env.d.ts b/frontend/portal/src/vite-env.d.ts index 6da12835bf..73919fcfe8 100644 --- a/frontend/portal/src/vite-env.d.ts +++ b/frontend/portal/src/vite-env.d.ts @@ -1,6 +1,14 @@ /// interface ImportMetaEnv { + /** Hosted SaaS Supabase project URL — in-app account-link login. Empty → link UI shows a configure state. */ + readonly VITE_SAAS_SUPABASE_URL: string; + /** Hosted SaaS Supabase anon/publishable key (public). */ + readonly VITE_SAAS_SUPABASE_ANON_KEY: string; + /** Hosted SaaS Java backend base URL — attended portal→SaaS reads (wallet, invoices, …) via apiClient.saas with the admin's JWT. */ + readonly VITE_SAAS_API_URL: string; + /** Stripe publishable key (pk_live_… / pk_test_…) used by embedded Checkout. */ + readonly VITE_STRIPE_PUBLISHABLE_KEY: string; /** URL of the editor app (app switcher + non-admin redirect). See portal/.env. */ readonly VITE_EDITOR_URL: string; /** Force MSW mocks on/off ("true"/"false"); empty falls back to dev default. */ diff --git a/frontend/shared/auth/ui/OAuthButtons.tsx b/frontend/shared/auth/ui/OAuthButtons.tsx index 4eb2810c4d..2651e14c36 100644 --- a/frontend/shared/auth/ui/OAuthButtons.tsx +++ b/frontend/shared/auth/ui/OAuthButtons.tsx @@ -31,7 +31,11 @@ export const oauthProviderConfig: Record< interface OAuthButtonsProps { onProviderClick: (provider: OAuthProvider) => void; isSubmitting: boolean; - layout?: "vertical" | "grid" | "icons"; + /** + * `fullwidth` is the SaaS-login canonical look — rounded pill buttons stacked + * vertically. `vertical` (default) uses the Spring/Mantine button shape. + */ + layout?: "vertical" | "grid" | "icons" | "fullwidth"; enabledProviders?: OAuthProvider[]; // List of full auth paths from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') ctaPrefix?: string; styleVariant?: "neutral" | "tinted" | "outline" | "light"; @@ -125,6 +129,39 @@ export default function OAuthButtons({ ); } + if (layout === "fullwidth") { + // Mirrors the SaaS editor login: rounded pill buttons stacked vertically, + // each with the provider's icon + "Sign in with X" label. + return ( +
    + {providers.map((p) => ( + + ))} +
    + ); + } + if (layout === "grid") { return (
    diff --git a/frontend/shared/auth/ui/SupabaseLoginForm.tsx b/frontend/shared/auth/ui/SupabaseLoginForm.tsx new file mode 100644 index 0000000000..7550bc88a5 --- /dev/null +++ b/frontend/shared/auth/ui/SupabaseLoginForm.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from "react-i18next"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; +import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm"; +import OAuthButtons from "@shared/auth/ui/OAuthButtons"; +import type { SupabaseLoginState } from "@shared/auth/ui/useSupabaseLogin"; +import "@shared/auth/ui/auth.css"; + +interface SupabaseLoginFormProps { + /** Login state + handlers, from useSupabaseLogin. */ + state: SupabaseLoginState; + /** Optional logo rendered above the form. */ + logoSrc?: string; + logoAlt?: string; +} + +/** + * Supabase counterpart to {@link SpringLoginForm}: the shared login body (error, + * SSO buttons, divider, email/password) wired to {@link useSupabaseLogin}. Reuses + * the same presentational pieces as the Spring form so it matches the SaaS login. + */ +export default function SupabaseLoginForm({ + state, + logoSrc, + logoAlt = "Stirling PDF", +}: SupabaseLoginFormProps) { + const { t } = useTranslation(); + const { + error, + providers, + hasProviders, + isSubmitting, + email, + password, + setEmail, + setPassword, + signInWithEmail, + signInWithProvider, + } = state; + + return ( + <> + {logoSrc && ( +
    + {logoAlt} +
    + )} + + + + {hasProviders && ( + + )} + + {hasProviders && ( +
    + + {t("signup.or", "or")} + +
    + )} + +
    + +
    + + ); +} diff --git a/frontend/shared/auth/ui/auth.css b/frontend/shared/auth/ui/auth.css index ce3d663a64..3fce26f7ce 100644 --- a/frontend/shared/auth/ui/auth.css +++ b/frontend/shared/auth/ui/auth.css @@ -729,3 +729,55 @@ text-align: center; flex-shrink: 0; } + +/* ── Fullwidth OAuth (SaaS-screen canonical look — shared so the portal link + modal matches the SaaS editor login exactly) ──────────────────────────── */ +.oauth-container-fullwidth { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.oauth-button-fullwidth { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1rem; + border: 1px solid #d1d5db; + border-radius: 100px; + background-color: #ffffff; + font-size: 1rem; + font-weight: 600; + color: #000000; + cursor: pointer; + gap: 0.5rem; + box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); + transition: + background-color 150ms ease, + box-shadow 150ms ease, + border-color 150ms ease; +} + +.oauth-button-fullwidth:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.oauth-button-fullwidth:hover:not(:disabled) { + background-color: #fafafa; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +[data-mantine-color-scheme="dark"] .oauth-button-fullwidth { + background-color: var(--bg-surface); + color: var(--text-primary); + border-color: var(--border-default); + box-shadow: none; +} + +[data-mantine-color-scheme="dark"] + .oauth-button-fullwidth:hover:not(:disabled) { + background-color: var(--bg-raised); + box-shadow: none; +} diff --git a/frontend/shared/auth/ui/useSupabaseLogin.ts b/frontend/shared/auth/ui/useSupabaseLogin.ts new file mode 100644 index 0000000000..a443ff22f0 --- /dev/null +++ b/frontend/shared/auth/ui/useSupabaseLogin.ts @@ -0,0 +1,134 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { Provider } from "@supabase/supabase-js"; +import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; + +/** + * Supabase counterpart to {@link useSpringLogin}: owns a login form's state and + * sign-in handlers, wired to the shared Supabase client ({@link configureSupabase}). + * Kept out of the `@shared/auth` barrel (like the rest of the Supabase path) so + * Spring-only consumers don't pull in `@supabase/supabase-js`; import via the + * subpath. + * + * Email/password resolves inline and fires {@link UseSupabaseLoginOptions.onSuccess}. + * OAuth triggers a full-page redirect to the provider; the session arrives on + * return (the host completes the flow from the Supabase auth-state change). + */ + +export interface SupabaseLoginSession { + /** Supabase access token (JWT) the caller hands to its backend. */ + access_token: string; +} + +export interface UseSupabaseLoginOptions { + /** OAuth provider ids to surface (e.g. "google", "github", "apple", "azure"). */ + providers?: string[]; + /** OAuth return URL. The Supabase project must allow-list it. */ + redirectTo?: string; + /** Run just before an OAuth redirect (e.g. stash a pending-link marker). */ + onBeforeOAuth?: (provider: string) => void; + /** Receives the session after a successful email/password sign-in. */ + onSuccess?: (session: SupabaseLoginSession) => void | Promise; +} + +export interface SupabaseLoginState { + email: string; + setEmail: (value: string) => void; + password: string; + setPassword: (value: string) => void; + error: string | null; + setError: (value: string | null) => void; + isSubmitting: boolean; + providers: string[]; + hasProviders: boolean; + signInWithEmail: () => Promise; + signInWithProvider: (provider: string) => Promise; +} + +const NOT_CONFIGURED_KEY = "auth.supabaseUnconfigured"; +const NOT_CONFIGURED_FALLBACK = + "Account login is unavailable — Supabase is not configured."; + +export function useSupabaseLogin( + options: UseSupabaseLoginOptions = {}, +): SupabaseLoginState { + const { providers = [], redirectTo, onBeforeOAuth, onSuccess } = options; + const { t } = useTranslation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const signInWithEmail = useCallback(async () => { + if (!email || !password) { + setError( + t("login.pleaseEnterBoth", "Please enter both email and password"), + ); + return; + } + const supabase = getSupabaseClient(); + if (!supabase) { + setError(t(NOT_CONFIGURED_KEY, NOT_CONFIGURED_FALLBACK)); + return; + } + try { + setIsSubmitting(true); + setError(null); + const { data, error: signInError } = + await supabase.auth.signInWithPassword({ + email: email.trim(), + password, + }); + if (signInError) { + setError(signInError.message); + } else if (data.session) { + await onSuccess?.({ access_token: data.session.access_token }); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSubmitting(false); + } + }, [email, password, onSuccess, t]); + + const signInWithProvider = useCallback( + async (provider: string) => { + const supabase = getSupabaseClient(); + if (!supabase) { + setError(t(NOT_CONFIGURED_KEY, NOT_CONFIGURED_FALLBACK)); + return; + } + try { + setIsSubmitting(true); + setError(null); + onBeforeOAuth?.(provider); + const { error: oauthError } = await supabase.auth.signInWithOAuth({ + provider: provider as Provider, + options: redirectTo ? { redirectTo } : undefined, + }); + if (oauthError) setError(oauthError.message); + // Success path redirects the browser; the session arrives on return. + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSubmitting(false); + } + }, + [onBeforeOAuth, redirectTo, t], + ); + + return { + email, + setEmail, + password, + setPassword, + error, + setError, + isSubmitting, + providers, + hasProviders: providers.length > 0, + signInWithEmail, + signInWithProvider, + }; +} diff --git a/frontend/shared/billing/MeterBar.tsx b/frontend/shared/billing/MeterBar.tsx new file mode 100644 index 0000000000..5fcfa605b5 --- /dev/null +++ b/frontend/shared/billing/MeterBar.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import type { MeterState } from "@shared/billing/format"; + +interface MeterBarProps { + state: MeterState; + /** Fill percentage 0–100. */ + pct: number; + /** The big figure — a used count or a spend amount ("120", "$45"). */ + figure: ReactNode; + /** Suffix after the figure ("/ 500 free PDFs", "/ $1,000 cap", "no cap"). */ + capSuffix: ReactNode; + /** Status-chip content; null/undefined hides the chip. */ + statusLabel?: ReactNode; + /** Footer meta line; null/undefined hides the line entirely. */ + meta?: ReactNode; + /** Hide the fill bar (e.g. uncapped). Shown by default. */ + showBar?: boolean; +} + +/** + * The usage-meter bar (the {@code paygf-meter} block) shared by the editor cloud + * surface and the admin portal. Callers own the copy — the editor passes i18n + * strings, the portal passes literals — so this carries no i18n dependency. + * Styling comes from each app's own {@code paygf-meter}/{@code payg-bar}/{@code + * payg-status} CSS. + */ +export function MeterBar({ + state, + pct, + figure, + capSuffix, + statusLabel, + meta, + showBar = true, +}: MeterBarProps) { + return ( +
    +
    +
    + {figure} + {capSuffix} +
    + {statusLabel != null && ( + + + {statusLabel} + + )} +
    + {showBar && ( +
    +
    +
    + )} + {meta != null &&
    {meta}
    } +
    + ); +} diff --git a/frontend/shared/billing/SpendCapControl.tsx b/frontend/shared/billing/SpendCapControl.tsx new file mode 100644 index 0000000000..c81e2c0ecb --- /dev/null +++ b/frontend/shared/billing/SpendCapControl.tsx @@ -0,0 +1,203 @@ +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; + /** 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; +} + +/** + * 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( + 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 ( +
    +
    + {presets.map((preset) => ( + + ))} + + + + + + {onSave && ( +
    + +
    + )} +
    + + {previewDocs != null && ( +
    + +
    +
    + {L.docsEstimate(previewDocs.toLocaleString())} +
    +
    + {L.docsRate(formatMinor(pricePerDocMinor ?? 0, currency))} +
    +
    +
    + )} + + {isNoCap &&
    {L.noCapDesc}
    } + {note &&
    {note}
    } +
    + ); +} diff --git a/frontend/shared/billing/format.ts b/frontend/shared/billing/format.ts new file mode 100644 index 0000000000..194a9bd686 --- /dev/null +++ b/frontend/shared/billing/format.ts @@ -0,0 +1,102 @@ +/** + * Pure money/meter helpers shared by the editor cloud surface and the admin + * portal. These carry backend-coupled invariants (the cap→PDF estimate mirrors + * the server's {@code docCapForMoney}; the meter bands mirror the BE warn/degrade + * thresholds), so keeping one copy is what stops the FE estimate silently + * diverging from the backend when a rate encoding changes. + */ + +/** Quick-amount cap presets (major currency units) offered everywhere. */ +export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const; + +/** Compact currency symbol; falls back to the ISO code for anything unmapped. */ +export function currencySymbol(currency: string | null | undefined): string { + switch ((currency ?? "").toLowerCase()) { + case "usd": + case "": + return "$"; + case "eur": + return "€"; + case "gbp": + return "£"; + default: + return (currency ?? "").toUpperCase() + " "; + } +} + +/** + * Format minor units as a compact-symbol amount ("$2.24", "£0.40"). Uses the + * short symbol (not Intl's "US$" currency display) and allows up to 3 fraction + * digits so sub-cent per-document rates don't round to $0. + */ +export function formatMinor( + minor: number, + currency: string | null | undefined, +): string { + const num = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 3, + }).format(minor / 100); + return `${currencySymbol(currency)}${num}`; +} + +/** Format a major-unit amount with the compact symbol ("$1,000", "€500"). */ +export function formatMoneyMajor( + major: number, + currency: string | null | undefined, +): string { + return `${currencySymbol(currency)}${major.toLocaleString()}`; +} + +/** + * Paid PDFs a monthly cap buys — mirror of the backend's {@code docCapForMoney}: + * floor(capMinor / rate). The one-time free grant is a separate lifetime pool and + * is NOT added here. Returns null when there's no cap or no resolvable rate (the + * caller hides the estimate). + */ +export function docCapForMoney( + capUsdMajor: number | null, + pricePerDocMinor: number | null | undefined, +): number | null { + if (capUsdMajor == null) return null; + const rate = + pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null; + return rate != null ? Math.floor((capUsdMajor * 100) / rate) : null; +} + +/** + * Short date for billing labels: "24 Jun" (period meters) or "24 Jun 2026" with + * {@code year}. Parses the date part of an ISO string as a local date. + */ +export function formatPeriodDate( + iso: string | null, + opts?: { year?: boolean }, +): string { + if (!iso) return ""; + const datePart = iso.split("T")[0]; + if (!datePart) return ""; + const [y, m, d] = datePart.split("-").map(Number); + if (!y || !m || !d) return datePart; + try { + return new Intl.DateTimeFormat(undefined, { + day: "numeric", + month: "short", + ...(opts?.year ? { year: "numeric" } : {}), + }).format(new Date(y, m - 1, d)); + } catch { + return datePart; + } +} + +export type MeterState = "FULL" | "WARNED" | "DEGRADED"; + +/** Warn (≥80%) / degrade (≥100%) band for a usage meter; mirrors the BE thresholds. */ +export 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 }; +} diff --git a/frontend/shared/billing/index.ts b/frontend/shared/billing/index.ts new file mode 100644 index 0000000000..76ab3b29d6 --- /dev/null +++ b/frontend/shared/billing/index.ts @@ -0,0 +1,24 @@ +export type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +} from "@shared/billing/types"; +export { + DEFAULT_CAP_PRESETS, + currencySymbol, + formatMinor, + formatMoneyMajor, + docCapForMoney, + formatPeriodDate, + meterState, + type MeterState, +} from "@shared/billing/format"; +export { MeterBar } from "@shared/billing/MeterBar"; +export { + SpendCapControl, + type SpendCapControlProps, + type SpendCapControlLabels, +} from "@shared/billing/SpendCapControl"; diff --git a/frontend/shared/billing/types.ts b/frontend/shared/billing/types.ts new file mode 100644 index 0000000000..83d0c66518 --- /dev/null +++ b/frontend/shared/billing/types.ts @@ -0,0 +1,67 @@ +/** + * The PAYG wallet contract — the single front-end mirror of the SaaS backend's + * {@code WalletSnapshotResponse} ({@code GET /api/v1/payg/wallet}). Both the + * editor cloud surface and the admin portal consume this, so a backend field + * change is a one-line update here instead of three diverging copies. + */ + +export type WalletStatus = "free" | "subscribed"; +export type WalletRole = "leader" | "member"; + +/** One team member's billing-relevant row (name/email + their period spend). */ +export interface WalletMember { + userId: string; + name: string; + email: string; + spendUnits: number; +} + +/** Current-period spend split across the billable feature buckets. */ +export interface WalletCategoryBreakdown { + api: number; + ai: number; + automation: number; +} + +/** A billable-activity row. The backend returns `[]` until the meter-event surface lands. */ +export interface WalletActivityRow { + id: number; + kind: string; + label: string; + ts: string; + docUnits: number; +} + +export interface Wallet { + /** Caller's primary team_id; null on the synthetic empty snapshot for team-less callers. */ + teamId: number | null; + status: WalletStatus; + role: WalletRole; + /** ISO yyyy-mm-dd. Stripe period when subscribed; calendar month when free. */ + billingPeriodStart: string; + billingPeriodEnd: string; + /** Free grant used (free teams) or documents processed this period (subscribed). */ + billableUsed: number; + /** Document ceiling for the window; null when subscribed-uncapped. */ + billableLimit: number | null; + /** One-time free grant size — a lifetime pool that survives subscribing. */ + freeAllowance: number; + /** Free grant still available; 0 = exhausted. */ + freeRemaining: number; + /** Paid per-document rate in minor units (may be fractional); null = unknown (render "unknown", never substitute). */ + pricePerDocMinor: number | null; + /** Lower-case ISO 4217; null when unknown. */ + currency: string | null; + /** Estimated charges so far this period in minor units; null when the rate is unknown. The Stripe invoice is authoritative. */ + estimatedBillMinor: number | null; + /** Monthly cap in major units when subscribed; null when noCap or free. */ + capUsd: number | null; + /** Only meaningful when subscribed. */ + noCap: boolean; + stripeSubscriptionId: string | null; + spendUnitsThisPeriod: number; + categoryBreakdown: WalletCategoryBreakdown; + /** Populated for the leader view; empty for members / single-seat tenants. */ + members: WalletMember[]; + recent: WalletActivityRow[]; +}