feat(saas): account-link — connected self-hosted billing (Mode A) [WIP, flag-gated] (#6738)

> **Draft / WIP.** Combined-billing **Mode A** (connected self-hosted).
Entirely behind `stirling.billing.account-link.enabled` (default **off**
→ beans absent → 404). Pairs with Stirling-PDF-SaaS PR #313 (twin
migration → `v3`).

## What this does

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

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

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

## Billing surface (Portal · Mode A states)

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

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

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

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

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

## Progress

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

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

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

**Linked · Free — Processor trial**


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

**Linked · Subscribed — Processor plan dashboard**

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

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

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


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

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

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

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
ConnorYoh
2026-06-29 13:35:07 +00:00
committed by GitHub
co-authored by James Brunton
parent 84739e8b0e
commit 14245d33d1
183 changed files with 11473 additions and 2477 deletions
+4
View File
@@ -30,6 +30,10 @@ tasks:
dev:proprietary: dev:proprietary:
desc: "Start backend dev server in proprietary mode" 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 ignore_error: true
vars: vars:
PORT: '{{.PORT | default "8080"}}' PORT: '{{.PORT | default "8080"}}'
+8
View File
@@ -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
+1
View File
@@ -1,3 +1,4 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*) # Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore. # stays ignored via the root .gitignore.
!.env.saas !.env.saas
!.env.proprietary
@@ -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").
*
* <p>Two calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* </ul>
*
* <p>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<String> 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<String> 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:
*
* <ul>
* <li>2xx → the parsed snapshot.
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
* the caller must BLOCK, not fail open.
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
* ("unknown" — the caller fails open).
* </ul>
*/
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
HttpResponse<String> 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<String> 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;
}
}
@@ -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").
*
* <p>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.
*
* <p>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<AccountLinkService.LinkStatus> status() {
return ResponseEntity.ok(service.status());
}
@PostMapping("/unlink")
public ResponseEntity<Void> unlink() {
service.unlink();
return ResponseEntity.noContent().build();
}
}
@@ -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).
*
* <p>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
* <b>off by default</b> and <b>dark</b> — 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).
*
* <p>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;
}
@@ -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").
*
* <p>{@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<DeviceCredential> 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));
}
}
@@ -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.
*
* <p>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/**");
}
}
@@ -0,0 +1,38 @@
package stirling.software.proprietary.accountlink;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.service.InternalApiClient;
/**
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
*
* <p>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 /<ctx>/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);
}
}
@@ -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.
*
* <p>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;
}
@@ -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<DeviceCredential, Long> {
/** The singleton credential, if this instance has linked. */
default Optional<DeviceCredential> findCredential() {
return findById(DeviceCredential.SINGLETON_ID);
}
}
@@ -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.
*
* <p>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<DeviceCredential> 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);
}
}
@@ -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.
*
* <p>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 <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
* allow").
*
* <p>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<InstanceEntitlement> 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<DeviceCredential> 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);
}
}
@@ -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
}
@@ -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);
}
}
@@ -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) {}
@@ -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.
*
* <p>Rules (in order):
*
* <ol>
* <li>Flag off → always allow (feature inert).
* <li>Manual tool → always allow (manual tools are free, never metered).
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
* <li>Billable + linked + entitled → allow.
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
* </ol>
*
* <p>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<InstanceEntitlement> 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<InstanceEntitlement> 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;
}
}
@@ -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.
*
* <p>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.
*
* <p>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;
}
}
@@ -33,6 +33,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.storage.repository", "stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.workflow.repository",
"stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.store",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.policy.source" "stirling.software.proprietary.policy.source"
}) })
@EntityScan({ @EntityScan({
@@ -41,6 +42,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.storage.model", "stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model", "stirling.software.proprietary.workflow.model",
"stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.store",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.policy.source" "stirling.software.proprietary.policy.source"
}) })
public class DatabaseConfig { public class DatabaseConfig {
@@ -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<String> response(int status, String body) {
HttpResponse<String> 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<String> resp =
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
ArgumentCaptor<HttpRequest> 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<String> 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<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":10,\"periodCapUnits\":100,\"state\":\"OK\"}");
ArgumentCaptor<HttpRequest> 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<String> 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<String> 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<String> 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<String> resp = response(204, "");
ArgumentCaptor<HttpRequest> 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<String> 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"));
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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: /<ctx>/api/v1/ai/** is billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
req.setContextPath("/stirling");
assertTrue(BillableOperationClassifier.isBillable(req));
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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();
}
}
@@ -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());
}
}
@@ -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").
*
* <p>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.
*
* <p>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<RegisterResponse> 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<InstanceRow>> list(Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
List<InstanceRow> 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<Void> 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<TeamMembership> 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);
}
}
@@ -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").
*
* <p>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.
*
* <p>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<LinkedInstance> 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<LinkedInstance> 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);
}
}
}
@@ -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").
*
* <p>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).
*
* <p>Read-only and <b>path-scoped to {@code /api/v1/instance/**}</b>: 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));
}
}
@@ -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 <b>device
* credential</b> — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
* credential is scoped here and nowhere else.
*
* <p>{@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.
*
* <p>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<WhoAmIResponse> 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<Void> 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<EntitlementResponse> 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";
}
}
@@ -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).
*
* <p>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.
*
* <p>{@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;
}
@@ -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").
*
* <p>Deliberately <em>not</em> 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;
}
}
@@ -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<LinkedInstance, Long> {
/**
* Active-credential lookup for the device-credential auth filter (revoked rows never match).
*/
Optional<LinkedInstance> findByDeviceIdAndRevokedAtIsNull(String deviceId);
/** Backs the portal "Linked instances" list (includes revoked, newest first). */
List<LinkedInstance> 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);
}
@@ -14,12 +14,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Profile("saas") @Profile("saas")
@EnableJpaRepositories( @EnableJpaRepositories(
basePackages = { basePackages = {
"stirling.software.saas.accountlink",
"stirling.software.saas.repository", "stirling.software.saas.repository",
"stirling.software.saas.billing.repository", "stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository", "stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository" "stirling.software.saas.payg.repository"
}) })
@EntityScan({ @EntityScan({
"stirling.software.saas.accountlink",
"stirling.software.saas.model", "stirling.software.saas.model",
"stirling.software.saas.billing.model", "stirling.software.saas.billing.model",
"stirling.software.saas.ai.model", "stirling.software.saas.ai.model",
@@ -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.
*
* <p>{@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.
*
* <p>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.
*
* <p>{@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<InvoiceResponse>> 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<TeamMembership> 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<PaygTeamExtensions> ext = extRepo.findById(teamId);
if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
return ResponseEntity.ok(List.of());
}
int safeLimit = clampLimit(limit);
List<InvoiceResponse> 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();
}
}
@@ -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.
*
* <p>{@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.
*
* <p>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<PaymentMethodResponse> get(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return ResponseEntity.ok(PaymentMethodResponse.absent());
}
Long teamId = rows.get(0).getTeam().getId();
Optional<PaygTeamExtensions> 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));
}
}
@@ -15,7 +15,9 @@ import stirling.software.saas.payg.model.FeatureSet;
* <p>State transitions: * <p>State transitions:
* *
* <ul> * <ul>
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally. * <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} (uncapped).
* <li>{@code capUnits <= 0} (an explicit $0 cap) → {@code DEGRADED}: metered work blocked, only
* the free grant + manual tools run.
* <li>{@code spend / cap &lt; warnPct} → {@code FULL}. * <li>{@code spend / cap &lt; warnPct} → {@code FULL}.
* <li><b>MINIMAL semantics:</b> under DEGRADED+MINIMAL manual server-side tools (gated by {@link * <li><b>MINIMAL semantics:</b> under DEGRADED+MINIMAL manual server-side tools (gated by {@link
* FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link * FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link
@@ -49,9 +51,19 @@ public final class CapEvaluator {
int degradeAtPct, int degradeAtPct,
FeatureSet degradedFeatureSet) { FeatureSet degradedFeatureSet) {
if (capUnits == null || capUnits <= 0) { if (capUnits == null) {
// No cap configured → uncapped, full feature set.
return full(); 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) { if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) {
// Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise // Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise
// degradation. The admin endpoints that set the policy should validate; this // degradation. The admin endpoints that set the policy should validate; this
@@ -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).
*
* <p>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.
*
* <p>{@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<InvoiceRow> findRecentByCustomer(String stripeCustomerId, int limit) {
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
return List.of();
}
int safeLimit = Math.max(1, Math.min(limit, 100));
List<InvoiceRow> 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<String, Long> 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).
*
* <p>Only the <b>metered</b> 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 <b>omitted</b> from the map, so {@code
* InvoiceRow.pdfsProcessed} stays {@code null} and the column renders "—" rather than "0".
*
* <p>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<String, Long> sumBilledUnits(List<String> 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<String, Long> 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());
}
}
@@ -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.
*
* <p>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<CardSummary> findDefaultCard(String stripeCustomerId) {
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
return Optional.empty();
}
try {
List<CardSummary> 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;
}
}
}
@@ -10,6 +10,7 @@ import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.model.User;
import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
import stirling.software.saas.service.SaasTeamService; import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.service.SupabaseUserService; import stirling.software.saas.service.SupabaseUserService;
@@ -80,7 +82,10 @@ public class SupabaseSecurityConfig {
private long clockSkewSeconds; private long clockSkewSeconds;
@Bean @Bean
SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) SecurityFilterChain saasSecurityFilterChain(
HttpSecurity http,
JwtDecoder jwtDecoder,
ObjectProvider<DeviceCredentialAuthenticationFilter> deviceCredentialFilterProvider)
throws Exception { throws Exception {
// CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in // 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 // Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no
@@ -135,6 +140,16 @@ public class SupabaseSecurityConfig {
.jwtAuthenticationConverter( .jwtAuthenticationConverter(
SupabaseSecurityConfig SupabaseSecurityConfig
::toAuthentication))); ::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(); return http.build();
} }
@@ -19,6 +19,7 @@ import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.saas.accountlink.LinkedInstanceRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamInvitation; import stirling.software.saas.model.TeamInvitation;
@@ -45,6 +46,7 @@ public class SaasTeamService {
private final UserRoleService userRoleService; private final UserRoleService userRoleService;
private final SaasTeamExtensionService saasTeamExtensionService; private final SaasTeamExtensionService saasTeamExtensionService;
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository; private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
private final LinkedInstanceRepository linkedInstanceRepository;
private final stirling.software.proprietary.security.service.UserService userService; private final stirling.software.proprietary.security.service.UserService userService;
public static final String DEFAULT_TEAM_NAME = "Default"; 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, * accept. The message points them at the right remedy — cancel the plan if the team is paid,
* otherwise transfer leadership first. * otherwise transfer leadership first.
* *
* <p>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 * @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) { private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
Team team = membership.getTeam(); Team team = membership.getTeam();
if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) { boolean personal = saasTeamExtensionService.isPersonal(team);
// Personal teams are deleted on accept; non-leaders leaving never orphans a team. if (!personal && !membership.isLeader()) {
// A non-leader leaving a shared team never orphans it.
continue; continue;
} }
// Only reached for a non-personal team the user leads — at most one such team in the if (!personal
// one-team-per-user model — so this count runs ~once, not per membership. && membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER)
if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) { > 1) {
// Another leader remains, so the team keeps an owner. // Another leader remains, so the team keeps an owner.
continue; 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)) { if (hasActivePaidSubscription(team)) {
throw new IllegalStateException( throw new IllegalStateException(
"Your team has an active plan and you are its last leader. Cancel the plan" "Your team has an active plan and you are its last leader. Cancel the plan"
@@ -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.';
@@ -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<RegisterResponse> 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<RegisterResponse> 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<RegisterResponse> 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<RegisterResponse> 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<Void> 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<Void> 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;
}
}
@@ -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<LinkedInstance> 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;
}
}
@@ -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);
}
}
@@ -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<EntitlementResponse> 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<EntitlementResponse> 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<EntitlementResponse> resp = controller().entitlement(anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(entitlementService, billingService);
}
@Test
void revokeSelf_callsServiceWithTokenIdentityAndReturns204() {
Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L);
ResponseEntity<Void> 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<Void> resp = controller().revokeSelf(anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(accountLinkService);
}
@Test
void whoami_returnsResolvedInstanceAndTeam() {
Authentication token = new LinkedInstanceAuthenticationToken(5L, 9L);
ResponseEntity<InstanceController.WhoAmIResponse> 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);
}
}
@@ -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<List<InvoiceResponse>> 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<List<InvoiceResponse>> 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<List<InvoiceResponse>> 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<List<InvoiceResponse>> 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<List<InvoiceResponse>> 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;
}
}
@@ -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<PaymentMethodResponse> 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<PaymentMethodResponse> 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<PaymentMethodResponse> 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<PaymentMethodResponse> 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<PaymentMethodResponse> 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());
}
}
@@ -29,10 +29,22 @@ class CapEvaluatorTest {
} }
@Test @Test
void zeroCap_treatedAsUnlimitedForSafety() { void zeroCap_blocksMeteredWork() {
// Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL). // 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); 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 @Test
@@ -243,6 +243,7 @@ class SupabaseSecurityConfigMoreTest {
@Test @Test
@DisplayName("builds and returns the SecurityFilterChain from http.build()") @DisplayName("builds and returns the SecurityFilterChain from http.build()")
@SuppressWarnings("unchecked")
void buildsFilterChain() throws Exception { void buildsFilterChain() throws Exception {
HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS);
// http.build() returns DefaultSecurityFilterChain, so stub with that concrete type. // http.build() returns DefaultSecurityFilterChain, so stub with that concrete type.
@@ -250,8 +251,16 @@ class SupabaseSecurityConfigMoreTest {
mock(org.springframework.security.web.DefaultSecurityFilterChain.class); mock(org.springframework.security.web.DefaultSecurityFilterChain.class);
when(http.build()).thenReturn(built); 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 = SecurityFilterChain result =
config(new ApplicationProperties()).saasSecurityFilterChain(http, jwtDecoder); config(new ApplicationProperties())
.saasSecurityFilterChain(http, jwtDecoder, deviceFilterProvider);
assertThat(result).isSameAs(built); assertThat(result).isSameAs(built);
} }
@@ -23,6 +23,8 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; 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.InvitationStatus;
import stirling.software.common.model.enumeration.Role; 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.Authority;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.saas.accountlink.LinkedInstanceRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamInvitation; import stirling.software.saas.model.TeamInvitation;
@@ -62,6 +65,7 @@ class SaasTeamServiceTest {
@Mock private UserRoleService userRoleService; @Mock private UserRoleService userRoleService;
@Mock private SaasTeamExtensionService saasTeamExtensionService; @Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository; @Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository;
@Mock private LinkedInstanceRepository linkedInstanceRepository;
@Mock private stirling.software.proprietary.security.service.UserService userService; @Mock private stirling.software.proprietary.security.service.UserService userService;
@InjectMocks private SaasTeamService service; @InjectMocks private SaasTeamService service;
@@ -1383,4 +1387,92 @@ class SaasTeamServiceTest {
return saved; 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;
}
}
} }
+4 -4
View File
@@ -24,10 +24,10 @@
/editor/.env.local /editor/.env.local
/editor/.env.*.local /editor/.env.*.local
# Root .gitignore ignores all .env* - whitelist our committed ones here # Root .gitignore ignores all .env* - whitelist only our committed ones, anchored
!.env # to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS
!.env.desktop # mock env is injected via .storybook/main.ts, not a file).
!.env.saas !/portal/.env
!/editor/.env !/editor/.env
!/editor/.env.desktop !/editor/.env.desktop
!/editor/.env.saas !/editor/.env.saas
+10
View File
@@ -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; return config;
}, },
}; };
+49 -6
View File
@@ -14,10 +14,12 @@ import { MantineProvider } from "@mantine/core";
void React; void React;
import { TierProvider, type Tier } from "@portal/contexts/TierContext"; import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider } from "@portal/contexts/ThemeContext"; import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext"; import { UIProvider } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme"; import { mantineTheme } from "@portal/theme/mantineTheme";
import { handlers } from "@portal/mocks/handlers"; import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@shared/tokens/tokens.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. // Start MSW once. Storybook runs in a browser so this uses the service worker.
initialize({ onUnhandledRequest: "bypass" }, handlers); 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<ReturnType<typeof saasStub.auth.getSession>>;
/** /**
* Bridge between Storybook's `tier` global toolbar and the actual TierProvider. * Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
* Without this the toolbar would just change a label; with it, every story * 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 withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro"; 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. // withThemeByDataAttribute exposes the toolbar theme as the `theme` global.
// Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings, // Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings,
// default surfaces) follows the dark toggle alongside the SUI CSS variables. // default surfaces) follows the dark toggle alongside the SUI CSS variables.
@@ -78,12 +103,16 @@ const withProviders: Decorator = (Story, context) => {
<MemoryRouter initialEntries={["/"]}> <MemoryRouter initialEntries={["/"]}>
<ThemeProvider> <ThemeProvider>
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}> <MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
<TierKey tier={tier}> {/* LinkProvider must wrap TierProvider: TierContext derives its tier
<UIProvider> from useLink() (matches App.tsx's nesting). */}
<ThemeWatcher /> <LinkProvider key={linkState} initialState={linkState}>
<Story /> <TierKey tier={tier}>
</UIProvider> <UIProvider>
</TierKey> <ThemeWatcher />
<Story />
</UIProvider>
</TierKey>
</LinkProvider>
</MantineProvider> </MantineProvider>
</ThemeProvider> </ThemeProvider>
</MemoryRouter> </MemoryRouter>
@@ -128,6 +157,20 @@ const preview: Preview = {
dynamicTitle: true, 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: [ decorators: [
withProviders, withProviders,
@@ -1,250 +1,59 @@
/** /**
* Reusable monthly spend-cap control. * 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
* One inline row — preset chips, a custom-entry pill that matches the presets, * {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code
* a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs / * onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is
* month" estimate. Extracted from the subscribed plan-page cap editor so the * unchanged, so the plan-page cap editor and the upgrade-checkout flow keep
* exact same control drives the upgrade checkout flow. * consuming it as before.
*
* <h2>Currency-agnostic by design</h2>
*
* The control never decides a currency. It takes {@code pricePerDocMinor} +
* {@code currency} and renders whatever it's handed: the subscribed plan page
* passes the team's real Stripe-subscription rate/currency; the unsubscribed
* checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
* yet) plus a {@code note} explaining the cap is editable later. When no rate
* is supplied the estimate simply hides.
*
* <h2>Controlled</h2>
*
* Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
* real $0 cap that keeps everything free) + {@code onChange}. The parent owns
* the working value. When {@code onSave} is provided the control renders the
* inline Save button and computes "dirty" against {@code savedCapUsd}.
*/ */
import React, { useState } from "react"; import React from "react";
import { Button } from "@mantine/core";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
DEFAULT_CAP_PRESETS,
SpendCapControl as SharedSpendCapControl,
} from "@shared/billing";
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import "./SpendCapControl.css"; import "./SpendCapControl.css";
// Quick amounts offered everywhere — recognition over recall. export { DEFAULT_CAP_PRESETS };
export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
export interface SpendCapControlProps { export interface SpendCapControlProps {
/** Current cap in major currency units; {@code null} = no cap. Controlled. */
capUsd: number | null; capUsd: number | null;
/** Working-value setter. {@code null} signals no-cap. */
onChange: (capUsd: number | null) => void; onChange: (capUsd: number | null) => void;
/** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
pricePerDocMinor?: number | null; pricePerDocMinor?: number | null;
/** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
currency?: string | null; currency?: string | null;
/** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
presets?: readonly number[]; presets?: readonly number[];
/**
* When provided, the control renders an inline Save button. Receives whole
* major units, or {@code null} for no-cap.
*/
onSave?: (capUsd: number | null) => Promise<void> | void; onSave?: (capUsd: number | null) => Promise<void> | void;
/** Label for the Save button. */
saveLabel?: string; 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; savedCapUsd?: number | null;
/** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
note?: React.ReactNode; note?: React.ReactNode;
} }
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
function formatMinor(
minor: number,
currency: string | null | undefined,
): string {
const code = (currency ?? "usd").toUpperCase();
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
// Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
// rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
maximumFractionDigits: 3,
}).format(minor / 100);
} catch {
return `${(minor / 100).toFixed(2)} ${code}`;
}
}
/** Currency symbol for compact inline use; falls back to the ISO code. */
function currencySymbol(currency: string | null | undefined): string {
switch ((currency ?? "").toLowerCase()) {
case "usd":
case "":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
default:
return currency!.toUpperCase() + " ";
}
}
const SpendCapControl: React.FC<SpendCapControlProps> = ({ const SpendCapControl: React.FC<SpendCapControlProps> = ({
capUsd,
onChange,
pricePerDocMinor,
currency,
presets = DEFAULT_CAP_PRESETS,
onSave,
saveLabel, saveLabel,
savedCapUsd, ...rest
note,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const sym = currencySymbol(currency);
const isNoCap = capUsd === null;
const presetSelected = capUsd != null && presets.includes(capUsd);
// Custom is "active" when a cap is set that isn't one of the presets — i.e.
// the value came from the custom pill.
const customActive = capUsd != null && !presets.includes(capUsd);
// Local mirror of the custom field's text so partial/empty entry doesn't get
// clobbered by the controlled value. Seeded from a non-preset incoming cap.
const [customText, setCustomText] = useState<string>(
customActive ? String(capUsd) : "",
);
// Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
// one-time free grant is a separate lifetime pool and is NOT added here —
// this is the paid PDFs the monthly cap buys.
const rate =
pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
const previewDocs =
capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
const selectPreset = (preset: number) => {
setCustomText("");
onChange(preset);
};
const selectNoCap = () => {
setCustomText("");
onChange(null);
};
const onCustomInput = (raw: string) => {
// Digits only; an empty field reads as "no custom value yet" → 0 so the
// estimate still renders sensibly without flipping to no-cap.
const cleaned = raw.replace(/[^0-9]/g, "");
setCustomText(cleaned);
const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
onChange(Number.isNaN(v) ? 0 : v);
};
const handleSave = async () => {
if (!onSave) return;
setSaving(true);
try {
await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
} finally {
setSaving(false);
}
};
return ( return (
<div className="scc"> <SharedSpendCapControl
<div className="scc-row"> {...rest}
{presets.map((preset) => ( labels={{
<button custom: t("payg.cap.custom", "Custom"),
key={preset} amountAria: t("payg.cap.amount", "Cap amount"),
type="button" noCap: t("payg.cap.noCapLabel", "No cap"),
className="scc-chip" save: saveLabel ?? t("payg.cap.save", "Update cap"),
data-selected={presetSelected && capUsd === preset} docsEstimate: (docs) =>
onClick={() => selectPreset(preset)} t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
> docs,
{sym} }),
{preset.toLocaleString()} docsRate: (rate) =>
</button> t("payg.cap.docsRate", "at {{rate}} / PDF", { rate }),
))} noCapDesc: t(
"payg.cap.noCapDesc",
{/* Custom-entry pill — dashed until it carries a value, then it fills "Usage is billed without an upper limit. You can re-enable a cap at any time.",
like a selected chip. */} ),
<label className="scc-custom" data-active={customActive}> }}
<span className="scc-custom__symbol">{sym}</span> />
<input
className="scc-custom__input"
inputMode="numeric"
value={customActive ? customText : ""}
placeholder={t("payg.cap.custom", "Custom")}
aria-label={t("payg.cap.amount", "Cap amount")}
onChange={(e) => onCustomInput(e.target.value)}
/>
</label>
<button
type="button"
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
data-selected={isNoCap}
onClick={selectNoCap}
>
{t("payg.cap.noCapLabel", "No cap")}
</button>
{onSave && (
<Button
variant="default"
size="xs"
className="scc-row__spacer"
disabled={!dirty || saving}
loading={saving}
leftSection={<LocalIcon icon="check-rounded" />}
onClick={handleSave}
>
{saveLabel ?? t("payg.cap.save", "Update cap")}
</Button>
)}
</div>
{previewDocs != null && (
<div className="scc-estimate">
<DescriptionIcon
className="scc-estimate__icon"
sx={{ fontSize: 22 }}
/>
<div>
<div className="scc-estimate__main">
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
docs: previewDocs.toLocaleString(),
})}
</div>
<div className="scc-estimate__sub">
{t("payg.cap.docsRate", "at {{rate}} / PDF", {
rate: formatMinor(pricePerDocMinor ?? 0, currency),
})}
</div>
</div>
</div>
)}
{isNoCap && (
<div className="scc-note">
{t(
"payg.cap.noCapDesc",
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
)}
</div>
)}
{note && <div className="scc-note">{note}</div>}
</div>
); );
}; };
@@ -7,36 +7,10 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useWallet, type Wallet } from "@app/hooks/useWallet"; 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/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.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 ────────────────────────────────────────────── // ─── One-time free grant meter ──────────────────────────────────────────────
export interface FreeSnapshot { export interface FreeSnapshot {
@@ -78,38 +52,20 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
: t("payg.free.state.plentyLeft", "Plenty left"); : t("payg.free.state.plentyLeft", "Plenty left");
return ( return (
<div className="paygf-meter" data-state={state}> <MeterBar
<div className="paygf-meter__top"> state={state}
<div className="paygf-meter__figure"> pct={pct}
<span className="paygf-meter__num"> figure={snap.billableUsed.toLocaleString()}
{snap.billableUsed.toLocaleString()} capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
</span> limit: snap.billableLimit.toLocaleString(),
<span className="paygf-meter__cap"> })}
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", { statusLabel={stateLabel}
limit: snap.billableLimit.toLocaleString(), meta={
})}
</span>
</div>
<span className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<span> <span>
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")} {t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
</span> </span>
</div> }
</div> />
); );
} }
@@ -158,45 +114,28 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
const symbol = currencySymbol(snap.currency); const symbol = currencySymbol(snap.currency);
return ( return (
<div className="paygf-meter" data-state={state}> <MeterBar
<div className="paygf-meter__top"> state={state}
<div className="paygf-meter__figure"> pct={pct}
<span className="paygf-meter__num"> figure={`${symbol}${snap.spent.toLocaleString()}`}
{symbol} capSuffix={t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
{snap.spent.toLocaleString()} amount: `${symbol}${snap.cap.toLocaleString()}`,
})}
statusLabel={stateLabel}
meta={
<>
<span>
{t(
"payg.spendCapMeter.metaCategories",
"Automation · AI · API spend",
)}
</span> </span>
<span className="paygf-meter__cap"> <span className="payg-hero__meta-dot"></span>
{t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", { <span>
amount: `${symbol}${snap.cap.toLocaleString()}`, {t("payg.spendCapMeter.resets", "Resets each billing period")}
})}
</span> </span>
</div> </>
<span className="payg-status" data-state={state}> }
<span className="payg-status__dot" /> />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<span>
{t(
"payg.spendCapMeter.metaCategories",
"Automation · AI · API spend",
)}
</span>
<span className="payg-hero__meta-dot"></span>
<span>
{t("payg.spendCapMeter.resets", "Resets each billing period")}
</span>
</div>
</div>
); );
} }
+18 -115
View File
@@ -50,123 +50,26 @@ import apiClient from "@app/services/apiClient";
import { createPortalSession } from "@app/services/billing"; import { createPortalSession } from "@app/services/billing";
import { openExternal } from "@app/platform/openExternal"; import { openExternal } from "@app/platform/openExternal";
import { getWalletDevPreview } from "@app/hooks/walletDevPreview"; import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
import type {
Wallet,
WalletStatus,
WalletRole,
WalletMember,
WalletCategoryBreakdown,
WalletActivityRow,
} from "@shared/billing";
// ─── Public types ─────────────────────────────────────────────────────── // ─── Public types ───────────────────────────────────────────────────────
// The wallet contract lives in @shared/billing (shared with the admin portal).
export type WalletStatus = "free" | "subscribed"; // Re-exported so existing `@app/hooks/useWallet` importers keep their imports.
export type WalletRole = "leader" | "member"; export type {
Wallet,
/** WalletStatus,
* A single team member's billing-relevant info — name + email for the avatar WalletRole,
* row, {@code spendUnits} for their per-member usage display. Mirrors a row of WalletMember,
* the backend's {@code members} array on {@code WalletSnapshot} (joined with WalletCategoryBreakdown,
* {@code team_memberships}). WalletActivityRow,
*/ };
export interface WalletMember {
/** Supabase user id of the member. */
userId: string;
name: string;
email: string;
/** Member's current-period billable spend. */
spendUnits: number;
}
/**
* Per-category breakdown of current-period spend in billable units. The
* categories mirror the {@code FeatureGate} buckets the backend tracks:
* server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and
* pipeline / automation runs ({@code automation}). Numbers sum to {@code
* billableUsed} (modulo rounding in mock data).
*/
export interface WalletCategoryBreakdown {
api: number;
ai: number;
automation: number;
}
/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */
export interface Wallet {
/**
* The caller's primary team_id. Needed when invoking Supabase edge functions
* (create-checkout-session, etc.) that run outside Spring Security and have
* no other way to resolve the caller's team. May be null on the synthetic
* empty snapshot returned to anonymous / team-less callers.
*/
teamId: number | null;
status: WalletStatus;
role: WalletRole;
/**
* ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed;
* the calendar month for free teams.
*/
billingPeriodStart: string;
billingPeriodEnd: string;
/**
* For a free team: the one-time free documents used so far ({@code
* freeAllowance freeRemaining}). For a subscribed team: documents
* processed this month across automation + AI + API.
*/
billableUsed: number;
/**
* The team's document ceiling for the matching window: the one-time free
* grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap
* {@code floor(cap / perDocRate)} for capped subscribed teams; null when
* subscribed with no cap (uncapped).
*/
billableLimit: number | null;
/**
* The team's one-time free document grant size — the "N" in "X of N free".
* A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets
* and is not lost when the team subscribes.
*/
freeAllowance: number;
/**
* One-time free documents still available to the team
* ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* Survives subscribing — a subscribed team keeps any unused grant.
*/
freeRemaining: number;
/**
* Paid per-document rate in minor units of {@link Wallet#currency} (may be
* fractional); null when the rate can't be resolved — render "unknown",
* never substitute.
*/
pricePerDocMinor: number | null;
/** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */
currency: string | null;
/**
* Estimated charges so far this period in minor units of currency: paid
* (Stripe-metered) documents this period × rate. The free portion was
* already netted out at charge time. Informational — the Stripe invoice
* is authoritative. Null when the rate is unknown.
*/
estimatedBillMinor: number | null;
/** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */
capUsd: number | null;
/** Only meaningful when status=='subscribed'. */
noCap: boolean;
/** Stripe subscription id when subscribed; null when free. */
stripeSubscriptionId: string | null;
/** Current-period spend in billable units. */
spendUnitsThisPeriod: number;
/** Per-category spend breakdown (api / ai / automation). */
categoryBreakdown: WalletCategoryBreakdown;
/**
* Team members, populated for the leader view; empty for members or
* single-seat tenants. Leader-vs-member is still resolved via {@link
* Wallet#role} — this field just carries the per-member rows the leader's
* sub-cap table needs.
*/
members: WalletMember[];
/**
* Recent billable-activity rows. V1 returns {@code []} from the backend;
* the field exists so the Plan page can render an empty state without
* branching on undefined. Each entry is a {@code Record<string, unknown>}
* because the activity-row shape is not yet finalised — when the meter-
* event surface lands, this widens to a real interface.
*/
recent: Array<Record<string, unknown>>;
}
export interface UseWalletResult { export interface UseWalletResult {
wallet: Wallet | null; wallet: Wallet | null;
@@ -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 { .auth-dropdown-wrapper {
position: relative; position: relative;
} }
+19
View File
@@ -10,3 +10,22 @@ VITE_EDITOR_URL=/
# in production builds). The single-origin proxy task sets this to "false" so the # in production builds). The single-origin proxy task sets this to "false" so the
# portal uses the real backend. # portal uses the real backend.
VITE_PORTAL_MOCKS= 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=
@@ -49,8 +49,9 @@ appProcessor = "Processor"
appEditor = "Editor" appEditor = "Editor"
docsProcessed = "Docs processed" docsProcessed = "Docs processed"
docsCount = "{{docs}} docs" docsCount = "{{docs}} docs"
planPayAsYouGo = "Pay-as-you-go" planProcessor = "Processor plan"
planEnterprise = "Enterprise Plan" linkAccount = "Link Stirling account"
planEnterprise = "Enterprise plan"
[search] [search]
ariaLabel = "Search" ariaLabel = "Search"
@@ -94,6 +95,7 @@ general = "General"
authentication = "Authentication" authentication = "Authentication"
sessions = "Active sessions" sessions = "Active sessions"
early-access = "Early access" early-access = "Early access"
account-link = "Account link"
[settings.profile] [settings.profile]
accountFallback = "Account" accountFallback = "Account"
@@ -420,180 +422,6 @@ snapshot = "Snapshot: re-read the folder every run"
[sources.types.unknown] [sources.types.unknown]
label = "Source" 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] [documents]
title = "Documents" title = "Documents"
subtitle = "Review and approve documents moving through your pipelines." 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" title = "Something went wrong on this page"
description = "This view hit an unexpected error. Try again, or pick another section from the sidebar." description = "This view hit an unexpected error. Try again, or pick another section from the sidebar."
retry = "Try again" 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}}"
+66 -18
View File
@@ -5,6 +5,8 @@ import { AuthProvider } from "@shared/auth";
import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { TierProvider } from "@portal/contexts/TierContext"; 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 { UIProvider, useUI } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme"; import { mantineTheme } from "@portal/theme/mantineTheme";
import { AppShell } from "@portal/components/AppShell"; import { AppShell } from "@portal/components/AppShell";
@@ -13,6 +15,11 @@ import { AssistantButton } from "@portal/components/AssistantButton";
import { AssistantPanel } from "@portal/components/AssistantPanel"; import { AssistantPanel } from "@portal/components/AssistantPanel";
import { SearchModal } from "@portal/components/SearchModal"; import { SearchModal } from "@portal/components/SearchModal";
import { SettingsModal } from "@portal/components/SettingsModal"; 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"; import { ViewRouter } from "@portal/ViewRouter";
/** /**
@@ -58,8 +65,42 @@ function GlobalShortcuts() {
/** Bridges the Settings modal's open/close props to UIContext state. */ /** Bridges the Settings modal's open/close props to UIContext state. */
function SettingsHost() { function SettingsHost() {
const { settingsOpen, closeSettings } = useUI(); const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
return <SettingsModal open={settingsOpen} onClose={closeSettings} />; return (
<SettingsModal
open={settingsOpen}
onClose={closeSettings}
initialSection={settingsInitialSection}
/>
);
}
/**
* 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 (
<LinkAccountModal
open={linkModalOpen}
mode={linkModalMode}
onClose={closeLinkModal}
onLinked={onLinked}
/>
);
} }
/** /**
@@ -87,22 +128,29 @@ export function App() {
<ThemeProvider> <ThemeProvider>
<PortalMantineProvider> <PortalMantineProvider>
<AuthProvider mode="spring"> <AuthProvider mode="spring">
<TierProvider initialTier="pro"> <LinkProvider initialState="unlinked">
<BrowserRouter basename={basename}> {/* TierProvider sits INSIDE LinkProvider so it can derive the tier
<UIProvider> from the real link/subscription state when MSW mocks are off. */}
<GlobalShortcuts /> <TierProvider initialTier="pro">
<AuthGate> <BrowserRouter basename={basename}>
<AppShell> <UIProvider>
<RoutedContent /> <GlobalShortcuts />
</AppShell> <AuthGate>
<AssistantButton /> <AccountLinkProvider>
<AssistantPanel /> <AppShell>
<SearchModal /> <RoutedContent />
<SettingsHost /> </AppShell>
</AuthGate> <AssistantButton />
</UIProvider> <AssistantPanel />
</BrowserRouter> <SearchModal />
</TierProvider> <SettingsHost />
<LinkModalHost />
</AccountLinkProvider>
</AuthGate>
</UIProvider>
</BrowserRouter>
</TierProvider>
</LinkProvider>
</AuthProvider> </AuthProvider>
</PortalMantineProvider> </PortalMantineProvider>
</ThemeProvider> </ThemeProvider>
+2
View File
@@ -28,6 +28,8 @@ export function ViewRouter() {
<Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} /> <Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} />
<Route path={VIEW_PATHS.usage} element={<Usage />} /> <Route path={VIEW_PATHS.usage} element={<Usage />} />
<Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} /> <Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} />
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
<Route path="/account-link" element={<Navigate to="/" replace />} />
{/* Settings is a modal overlay, not a route (see AppShell + UIContext). */} {/* Settings is a modal overlay, not a route (see AppShell + UIContext). */}
{/* Unknown paths land on Home. */} {/* Unknown paths land on Home. */}
<Route path="*" element={<Navigate to={VIEW_PATHS.home} replace />} /> <Route path="*" element={<Navigate to={VIEW_PATHS.home} replace />} />
+2 -2
View File
@@ -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 { AgentsResponse } from "@portal/mocks/agents";
import type { Tier } from "@portal/contexts/TierContext"; 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. */ /** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> { export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
return httpJson<AgentsResponse>( return apiClient.local.json<AgentsResponse>(
`/v1/agents?tier=${encodeURIComponent(tier)}`, `/v1/agents?tier=${encodeURIComponent(tier)}`,
); );
} }
+9 -6
View File
@@ -1,15 +1,18 @@
import { httpJson } from "@portal/api/http"; import { apiClient } from "@portal/api/http";
/** GET /v1/assistant/suggestions */ /** GET /v1/assistant/suggestions */
export async function fetchAssistantSuggestions(): Promise<readonly string[]> { export async function fetchAssistantSuggestions(): Promise<readonly string[]> {
return httpJson<readonly string[]>("/v1/assistant/suggestions"); return apiClient.local.json<readonly string[]>("/v1/assistant/suggestions");
} }
/** POST /v1/assistant/messages */ /** POST /v1/assistant/messages */
export async function getAssistantReply(input: string): Promise<string> { export async function getAssistantReply(input: string): Promise<string> {
const res = await httpJson<{ reply: string }>("/v1/assistant/messages", { const res = await apiClient.local.json<{ reply: string }>(
method: "POST", "/v1/assistant/messages",
body: { input }, {
}); method: "POST",
body: { input },
},
);
return res.reply; return res.reply;
} }
+79
View File
@@ -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<Wallet> {
return apiClient.saas.json<Wallet>("/api/v1/payg/wallet");
}
// ────────────────────────────────────────────────────────────────────────────
// Cap — leader-only PATCH (real endpoint).
// ────────────────────────────────────────────────────────────────────────────
export async function updateCap(capUsd: number | null): Promise<void> {
await apiClient.saas.json<void>("/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<Invoice[]> {
return apiClient.saas.json<Invoice[]>(
`/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<PaymentMethod> {
return apiClient.saas.json<PaymentMethod>("/api/v1/payg/payment-method");
}
+3 -3
View File
@@ -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 { Tier } from "@portal/contexts/TierContext";
import type { DocsContent, DocsNavSection } from "@portal/mocks/docs"; import type { DocsContent, DocsNavSection } from "@portal/mocks/docs";
@@ -18,10 +18,10 @@ export type {
/** GET /v1/docs/nav — the docs nav tree. */ /** GET /v1/docs/nav — the docs nav tree. */
export async function fetchDocsNav(): Promise<DocsNavSection[]> { export async function fetchDocsNav(): Promise<DocsNavSection[]> {
return httpJson<DocsNavSection[]>("/v1/docs/nav"); return apiClient.local.json<DocsNavSection[]>("/v1/docs/nav");
} }
/** GET /v1/docs/content — the tier-scaled reference content. */ /** GET /v1/docs/content — the tier-scaled reference content. */
export async function fetchDocsContent(tier: Tier): Promise<DocsContent> { export async function fetchDocsContent(tier: Tier): Promise<DocsContent> {
return httpJson<DocsContent>(`/v1/docs/content?tier=${tier}`); return apiClient.local.json<DocsContent>(`/v1/docs/content?tier=${tier}`);
} }
+2 -2
View File
@@ -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 { DocumentsResponse } from "@portal/mocks/documents";
import type { Tier } from "@portal/contexts/TierContext"; import type { Tier } from "@portal/contexts/TierContext";
@@ -20,7 +20,7 @@ export {
/** GET /v1/documents?tier=… — summary strip + the review queue for the tier. */ /** GET /v1/documents?tier=… — summary strip + the review queue for the tier. */
export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> { export async function fetchDocuments(tier: Tier): Promise<DocumentsResponse> {
return httpJson<DocumentsResponse>( return apiClient.local.json<DocumentsResponse>(
`/v1/documents?tier=${encodeURIComponent(tier)}`, `/v1/documents?tier=${encodeURIComponent(tier)}`,
); );
} }
+2 -2
View File
@@ -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 { EditorDeploymentResponse } from "@portal/mocks/editorDeploy";
import type { Tier } from "@portal/contexts/TierContext"; import type { Tier } from "@portal/contexts/TierContext";
@@ -29,7 +29,7 @@ export {
export async function fetchEditorDeployment( export async function fetchEditorDeployment(
tier: Tier, tier: Tier,
): Promise<EditorDeploymentResponse> { ): Promise<EditorDeploymentResponse> {
return httpJson<EditorDeploymentResponse>( return apiClient.local.json<EditorDeploymentResponse>(
`/v1/editor/deployment?tier=${encodeURIComponent(tier)}`, `/v1/editor/deployment?tier=${encodeURIComponent(tier)}`,
); );
} }
+10 -6
View File
@@ -1,4 +1,4 @@
import { httpJson } from "@portal/api/http"; import { apiClient } from "@portal/api/http";
import type { import type {
ActivityEvent, ActivityEvent,
KpiEntry, KpiEntry,
@@ -23,25 +23,29 @@ export { PIPELINE_STAGES, PIPELINE_TEMPLATES } from "@portal/mocks/home";
/** GET /v1/analytics/usage?window=30d */ /** GET /v1/analytics/usage?window=30d */
export async function fetchUsageSeries(): Promise<UsageSeriesResponse> { export async function fetchUsageSeries(): Promise<UsageSeriesResponse> {
return httpJson<UsageSeriesResponse>("/v1/analytics/usage?window=30d"); return apiClient.local.json<UsageSeriesResponse>(
"/v1/analytics/usage?window=30d",
);
} }
/** GET /v1/activity?limit=8 */ /** GET /v1/activity?limit=8 */
export async function fetchRecentActivity(): Promise<ActivityEvent[]> { export async function fetchRecentActivity(): Promise<ActivityEvent[]> {
return httpJson<ActivityEvent[]>("/v1/activity?limit=8"); return apiClient.local.json<ActivityEvent[]>("/v1/activity?limit=8");
} }
/** GET /v1/home/kpis?tier=… */ /** GET /v1/home/kpis?tier=… */
export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> { export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
return httpJson<KpiEntry[]>(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`); return apiClient.local.json<KpiEntry[]>(
`/v1/home/kpis?tier=${encodeURIComponent(tier)}`,
);
} }
/** GET /v1/regions/health (Enterprise) */ /** GET /v1/regions/health (Enterprise) */
export async function fetchRegionHealth(): Promise<RegionHealth[]> { export async function fetchRegionHealth(): Promise<RegionHealth[]> {
return httpJson<RegionHealth[]>("/v1/regions/health"); return apiClient.local.json<RegionHealth[]>("/v1/regions/health");
} }
/** GET /v1/onboarding (Free) */ /** GET /v1/onboarding (Free) */
export async function fetchOnboarding(): Promise<OnboardingStep[]> { export async function fetchOnboarding(): Promise<OnboardingStep[]> {
return httpJson<OnboardingStep[]>("/v1/onboarding"); return apiClient.local.json<OnboardingStep[]>("/v1/onboarding");
} }
+131
View File
@@ -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<string, string>).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<string, string>).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" });
});
});
+150 -25
View File
@@ -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`. * ## Domains
* 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.
* *
* The shared `stirling_jwt` bearer token (set by the auth gate, and shared * apiClient.local Same-origin (vite proxy → this instance's local
* same-origin with the editor) is attached automatically so portal data calls * Stirling backend on :8080). Spring admin bearer
* are authenticated once real backend endpoints exist. * (`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 { 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 { export interface HttpRequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -20,6 +58,7 @@ export interface HttpRequestOptions {
signal?: AbortSignal; signal?: AbortSignal;
} }
/** Thrown by any apiClient call on non-2xx response, with the parsed body. */
export class HttpError extends Error { export class HttpError extends Error {
constructor( constructor(
public readonly status: number, 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 * Best-effort human-readable message from a thrown error: unwraps an
* {@link HttpError}'s ProblemDetail-ish body (`detail` / `message` / `error`) * {@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); return error instanceof Error ? error.message : String(error);
} }
function authHeader(): Record<string, string> { // ────────────────────────────────────────────────────────────────────────────
// Shared response handler
// ────────────────────────────────────────────────────────────────────────────
async function unwrap<T>(res: Response): Promise<T> {
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<string, string> {
const token = getStoredToken(); const token = getStoredToken();
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
/** async function localJson<T>(
* 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<T>(
path: string, path: string,
options: HttpRequestOptions = {}, options: HttpRequestOptions = {},
): Promise<T> { ): Promise<T> {
@@ -69,20 +150,64 @@ export async function httpJson<T>(
...(options.body !== undefined ...(options.body !== undefined
? { "Content-Type": "application/json" } ? { "Content-Type": "application/json" }
: {}), : {}),
...authHeader(), ...localAuthHeader(),
...options.headers, ...options.headers,
}, },
body: options.body !== undefined ? JSON.stringify(options.body) : undefined, body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: options.signal, signal: options.signal,
}); });
if (!res.ok) { return unwrap<T>(res);
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;
} }
// ────────────────────────────────────────────────────────────────────────────
// saas — hosted SaaS Java, admin's Supabase JWT
// ────────────────────────────────────────────────────────────────────────────
async function getSaasAccessToken(): Promise<string | null> {
ensureSaasSupabase();
const supabase = getSupabaseClient();
if (!supabase) return null;
const { data } = await supabase.auth.getSession();
return data.session?.access_token ?? null;
}
async function saasJson<T>(
path: string,
options: HttpRequestOptions = {},
): Promise<T> {
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<T>(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;
+17 -7
View File
@@ -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 { Tier } from "@portal/contexts/TierContext";
import type { import type {
ApiKey, ApiKey,
@@ -57,32 +57,42 @@ const q = (tier: Tier) => `?tier=${encodeURIComponent(tier)}`;
export async function fetchDeployments( export async function fetchDeployments(
tier: Tier, tier: Tier,
): Promise<DeploymentsResponse> { ): Promise<DeploymentsResponse> {
return httpJson<DeploymentsResponse>( return apiClient.local.json<DeploymentsResponse>(
`/v1/infrastructure/deployments${q(tier)}`, `/v1/infrastructure/deployments${q(tier)}`,
); );
} }
/** GET /v1/infrastructure/api-keys?tier=… */ /** GET /v1/infrastructure/api-keys?tier=… */
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> { export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
return httpJson<ApiKey[]>(`/v1/infrastructure/api-keys${q(tier)}`); return apiClient.local.json<ApiKey[]>(
`/v1/infrastructure/api-keys${q(tier)}`,
);
} }
/** GET /v1/infrastructure/security?tier=… */ /** GET /v1/infrastructure/security?tier=… */
export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> { export async function fetchSecurity(tier: Tier): Promise<SecurityConfig> {
return httpJson<SecurityConfig>(`/v1/infrastructure/security${q(tier)}`); return apiClient.local.json<SecurityConfig>(
`/v1/infrastructure/security${q(tier)}`,
);
} }
/** GET /v1/infrastructure/models?tier=… */ /** GET /v1/infrastructure/models?tier=… */
export async function fetchModels(tier: Tier): Promise<ModelsResponse> { export async function fetchModels(tier: Tier): Promise<ModelsResponse> {
return httpJson<ModelsResponse>(`/v1/infrastructure/models${q(tier)}`); return apiClient.local.json<ModelsResponse>(
`/v1/infrastructure/models${q(tier)}`,
);
} }
/** GET /v1/infrastructure/storage?tier=… */ /** GET /v1/infrastructure/storage?tier=… */
export async function fetchStorage(tier: Tier): Promise<StorageConfig> { export async function fetchStorage(tier: Tier): Promise<StorageConfig> {
return httpJson<StorageConfig>(`/v1/infrastructure/storage${q(tier)}`); return apiClient.local.json<StorageConfig>(
`/v1/infrastructure/storage${q(tier)}`,
);
} }
/** GET /v1/infrastructure/audit-log?tier=… */ /** GET /v1/infrastructure/audit-log?tier=… */
export async function fetchAuditLog(tier: Tier): Promise<AuditLogResponse> { export async function fetchAuditLog(tier: Tier): Promise<AuditLogResponse> {
return httpJson<AuditLogResponse>(`/v1/infrastructure/audit-log${q(tier)}`); return apiClient.local.json<AuditLogResponse>(
`/v1/infrastructure/audit-log${q(tier)}`,
);
} }
+123
View File
@@ -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();
});
});
+85
View File
@@ -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<LinkStatus> {
return apiClient.local.json<LinkStatus>(`${BASE}/link`, {
method: "POST",
body: req,
});
}
/** Linked / Not-linked for this instance. */
export async function fetchStatus(): Promise<LinkStatus> {
return apiClient.local.json<LinkStatus>(`${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<void> {
await apiClient.local.json<void>(`${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<LinkedInstanceRow[]> {
return apiClient.saas.json<LinkedInstanceRow[]>(`${BASE}/instances`);
}
/**
* Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT.
*/
export async function revokeInstance(instanceId: number): Promise<void> {
await apiClient.saas.json<void>(`${BASE}/instances/${instanceId}/revoke`, {
method: "POST",
});
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { httpJson } from "@portal/api/http"; import { apiClient } from "@portal/api/http";
import type { import type {
Notification, Notification,
NotificationCategory, NotificationCategory,
@@ -8,12 +8,12 @@ export type { Notification, NotificationCategory };
/** GET /v1/notifications */ /** GET /v1/notifications */
export async function fetchNotifications(): Promise<Notification[]> { export async function fetchNotifications(): Promise<Notification[]> {
return httpJson<Notification[]>("/v1/notifications"); return apiClient.local.json<Notification[]>("/v1/notifications");
} }
/** POST /v1/notifications/mark-all-read */ /** POST /v1/notifications/mark-all-read */
export async function markAllNotificationsRead(): Promise<void> { export async function markAllNotificationsRead(): Promise<void> {
await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", { await apiClient.local.json<{ ok: true }>("/v1/notifications/mark-all-read", {
method: "POST", method: "POST",
}); });
} }
+9 -9
View File
@@ -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"; import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops";
export type { FeaturedOp, OpResultMap }; export type { FeaturedOp, OpResultMap };
/** GET /v1/ops/featured */ /** GET /v1/ops/featured */
export async function fetchFeaturedOps(): Promise<FeaturedOp[]> { export async function fetchFeaturedOps(): Promise<FeaturedOp[]> {
return httpJson<FeaturedOp[]>("/v1/ops/featured"); return apiClient.local.json<FeaturedOp[]>("/v1/ops/featured");
} }
export class UnknownOpError extends Error { export class UnknownOpError extends Error {
@@ -21,13 +21,13 @@ export async function runSingleOp(
sample: string, sample: string,
): Promise<{ result: OpResultMap; durationMs: number }> { ): Promise<{ result: OpResultMap; durationMs: number }> {
try { try {
return await httpJson<{ result: OpResultMap; durationMs: number }>( return await apiClient.local.json<{
`/v1/ops/${encodeURIComponent(opId)}/run`, result: OpResultMap;
{ durationMs: number;
method: "POST", }>(`/v1/ops/${encodeURIComponent(opId)}/run`, {
body: { sample }, method: "POST",
}, body: { sample },
); });
} catch (err) { } catch (err) {
if (err instanceof HttpError && err.status === 404) { if (err instanceof HttpError && err.status === 404) {
throw new UnknownOpError(opId); throw new UnknownOpError(opId);
+3 -3
View File
@@ -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 { PipelinesResponse } from "@portal/mocks/pipelines";
import type { Tier } from "@portal/contexts/TierContext"; import type { Tier } from "@portal/contexts/TierContext";
@@ -18,7 +18,7 @@ export type {
/** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */ /** GET /v1/pipelines?tier=… — the deployed fleet plus tier-specific extras. */
export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> { export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
return httpJson<PipelinesResponse>( return apiClient.local.json<PipelinesResponse>(
`/v1/pipelines?tier=${encodeURIComponent(tier)}`, `/v1/pipelines?tier=${encodeURIComponent(tier)}`,
); );
} }
@@ -32,7 +32,7 @@ export async function fetchPipelines(tier: Tier): Promise<PipelinesResponse> {
* handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted. * handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted.
*/ */
export async function promoteToPolicy(id: string): Promise<{ ok: true }> { 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`, `/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`,
{ method: "POST" }, { method: "POST" },
); );
+16 -8
View File
@@ -1,4 +1,4 @@
import { httpJson } from "@portal/api/http"; import { apiClient } from "@portal/api/http";
import type { PoliciesResponse, Policy } from "@portal/mocks/policies"; import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
/** /**
@@ -45,12 +45,14 @@ export {
/** GET /api/v1/policies — the catalogue + every configured policy. */ /** GET /api/v1/policies — the catalogue + every configured policy. */
export async function fetchPolicies(): Promise<PoliciesResponse> { export async function fetchPolicies(): Promise<PoliciesResponse> {
return httpJson<PoliciesResponse>("/api/v1/policies"); return apiClient.local.json<PoliciesResponse>("/api/v1/policies");
} }
/** GET /api/v1/policies/{id} — one stored policy's raw record. */ /** GET /api/v1/policies/{id} — one stored policy's raw record. */
export async function fetchPolicy(id: string): Promise<Policy> { export async function fetchPolicy(id: string): Promise<Policy> {
return httpJson<Policy>(`/api/v1/policies/${encodeURIComponent(id)}`); return apiClient.local.json<Policy>(
`/api/v1/policies/${encodeURIComponent(id)}`,
);
} }
/** /**
@@ -58,14 +60,20 @@ export async function fetchPolicy(id: string): Promise<Policy> {
* assigns owner + team server-side and returns the stored policy with its id. * assigns owner + team server-side and returns the stored policy with its id.
*/ */
export async function savePolicy(policy: Policy): Promise<Policy> { export async function savePolicy(policy: Policy): Promise<Policy> {
return httpJson<Policy>("/api/v1/policies", { method: "POST", body: policy }); return apiClient.local.json<Policy>("/api/v1/policies", {
method: "POST",
body: policy,
});
} }
/** DELETE /api/v1/policies/{id} — remove a stored policy. */ /** DELETE /api/v1/policies/{id} — remove a stored policy. */
export async function deletePolicy(id: string): Promise<void> { export async function deletePolicy(id: string): Promise<void> {
await httpJson<void>(`/api/v1/policies/${encodeURIComponent(id)}`, { await apiClient.local.json<void>(
method: "DELETE", `/api/v1/policies/${encodeURIComponent(id)}`,
}); {
method: "DELETE",
},
);
} }
/** The async run acknowledgement: a run id to poll for status. */ /** 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. * run id. Runs regardless of the policy's enabled flag.
*/ */
export async function runPolicy(id: string): Promise<PolicyRunResponse> { export async function runPolicy(id: string): Promise<PolicyRunResponse> {
return httpJson<PolicyRunResponse>( return apiClient.local.json<PolicyRunResponse>(
`/api/v1/policies/${encodeURIComponent(id)}/run`, `/api/v1/policies/${encodeURIComponent(id)}/run`,
{ method: "POST" }, { method: "POST" },
); );
+2 -2
View File
@@ -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 { ComponentsResponse } from "@portal/mocks/sdkComponents";
import type { Tier } from "@portal/contexts/TierContext"; import type { Tier } from "@portal/contexts/TierContext";
@@ -22,7 +22,7 @@ export {
/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */ /** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */
export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> { export async function fetchComponents(tier: Tier): Promise<ComponentsResponse> {
return httpJson<ComponentsResponse>( return apiClient.local.json<ComponentsResponse>(
`/v1/components?tier=${encodeURIComponent(tier)}`, `/v1/components?tier=${encodeURIComponent(tier)}`,
); );
} }
+2 -2
View File
@@ -1,9 +1,9 @@
import { httpJson } from "@portal/api/http"; import { apiClient } from "@portal/api/http";
import type { QuickAction } from "@portal/mocks/search"; import type { QuickAction } from "@portal/mocks/search";
export type { QuickAction }; export type { QuickAction };
/** GET /v1/search/quick-actions */ /** GET /v1/search/quick-actions */
export async function fetchQuickActions(): Promise<QuickAction[]> { export async function fetchQuickActions(): Promise<QuickAction[]> {
return httpJson<QuickAction[]>("/v1/search/quick-actions"); return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
} }
+2 -2
View File
@@ -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 { SettingsSnapshot } from "@portal/mocks/settings";
import type { Tier } from "@portal/contexts/TierContext"; import type { Tier } from "@portal/contexts/TierContext";
@@ -13,7 +13,7 @@ export type {
/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */ /** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */
export async function fetchSettings(tier: Tier): Promise<SettingsSnapshot> { export async function fetchSettings(tier: Tier): Promise<SettingsSnapshot> {
return httpJson<SettingsSnapshot>( return apiClient.local.json<SettingsSnapshot>(
`/v1/settings?tier=${encodeURIComponent(tier)}`, `/v1/settings?tier=${encodeURIComponent(tier)}`,
); );
} }
+15 -7
View File
@@ -1,4 +1,4 @@
import { httpJson } from "@portal/api/http"; import { apiClient } from "@portal/api/http";
/** /**
* Sources service layer: the backend contract. * 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. */ /** GET /api/v1/sources: KPI strip + one row per source for the admin. */
export async function fetchSources(): Promise<SourcesResponse> { export async function fetchSources(): Promise<SourcesResponse> {
return httpJson<SourcesResponse>("/api/v1/sources"); return apiClient.local.json<SourcesResponse>("/api/v1/sources");
} }
/** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */ /** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */
export async function fetchSource(id: string): Promise<Source> { export async function fetchSource(id: string): Promise<Source> {
return httpJson<Source>(`/api/v1/sources/${encodeURIComponent(id)}`); return apiClient.local.json<Source>(
`/api/v1/sources/${encodeURIComponent(id)}`,
);
} }
/** POST /api/v1/sources: create (blank id) or update (matched id) a source. */ /** POST /api/v1/sources: create (blank id) or update (matched id) a source. */
export async function createSource(source: Source): Promise<Source> { export async function createSource(source: Source): Promise<Source> {
return httpJson<Source>("/api/v1/sources", { method: "POST", body: source }); return apiClient.local.json<Source>("/api/v1/sources", {
method: "POST",
body: source,
});
} }
/** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */ /** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */
export async function deleteSource(id: string): Promise<void> { export async function deleteSource(id: string): Promise<void> {
await httpJson<void>(`/api/v1/sources/${encodeURIComponent(id)}`, { await apiClient.local.json<void>(
method: "DELETE", `/api/v1/sources/${encodeURIComponent(id)}`,
}); {
method: "DELETE",
},
);
} }
-44
View File
@@ -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<UsageSeriesResponse> {
return httpJson<UsageSeriesResponse>("/v1/billing/usage");
}
/** GET /v1/billing/summary?tier=… — KPI strip + current-plan figures. */
export async function fetchBillingSummary(tier: Tier): Promise<BillingSummary> {
return httpJson<BillingSummary>(
`/v1/billing/summary?tier=${encodeURIComponent(tier)}`,
);
}
/** GET /v1/billing/plans — available plan catalogue. */
export async function fetchPlanOptions(): Promise<PlanOption[]> {
return httpJson<PlanOption[]>("/v1/billing/plans");
}
/** GET /v1/billing/history?tier=… — invoice / line-item history. */
export async function fetchBillingHistory(
tier: Tier,
): Promise<BillingHistoryRow[]> {
return httpJson<BillingHistoryRow[]>(
`/v1/billing/history?tier=${encodeURIComponent(tier)}`,
);
}
+4 -2
View File
@@ -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 { UsersResponse } from "@portal/mocks/users";
import type { Tier } from "@portal/contexts/TierContext"; import type { Tier } from "@portal/contexts/TierContext";
@@ -20,5 +20,7 @@ export {
/** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */ /** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */
export async function fetchUsers(tier: Tier): Promise<UsersResponse> { export async function fetchUsers(tier: Tier): Promise<UsersResponse> {
return httpJson<UsersResponse>(`/v1/users?tier=${encodeURIComponent(tier)}`); return apiClient.local.json<UsersResponse>(
`/v1/users?tier=${encodeURIComponent(tier)}`,
);
} }
+42
View File
@@ -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();
}
@@ -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();
});
});
@@ -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");
});
});
+114
View File
@@ -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);
});
});
+170
View File
@@ -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<T>(
name: string,
body: Record<string, unknown>,
): Promise<T> {
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<T>(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
* &lt;EmbeddedCheckoutProvider&gt; 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<CheckoutSession> {
const res = await invoke<CheckoutResponse>("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<string> {
const res = await invoke<PortalResponse>("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;
}
+11 -1
View File
@@ -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 { .portal-shell {
display: flex; display: flex;
min-height: 100vh; height: 100vh;
overflow: hidden;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text-2); color: var(--color-text-2);
} }
@@ -10,10 +18,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; /* prevent grid blowout on narrow content */ min-width: 0; /* prevent grid blowout on narrow content */
min-height: 0; /* allow the column to bound its children */
} }
.portal-shell__view { .portal-shell__view {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; /* scroll instead of growing past the viewport */
overflow-y: auto; overflow-y: auto;
animation: fadeInUp var(--motion-enter) both; animation: fadeInUp var(--motion-enter) both;
} }
+5 -1
View File
@@ -40,8 +40,12 @@ function ThemeToggle() {
} }
function TierSwitcher() { function TierSwitcher() {
const { tier, setTier } = useTier(); const { tier, setTier, isDerived } = useTier();
const info = TIER_INFO[tier]; 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 ( return (
<Dropdown.Root align="end"> <Dropdown.Root align="end">
<Dropdown.Trigger> <Dropdown.Trigger>
@@ -31,7 +31,9 @@ import {
PoliciesIcon, PoliciesIcon,
InfrastructureIcon, InfrastructureIcon,
SparklesIcon, SparklesIcon,
LinkIcon,
} from "@portal/components/icons"; } from "@portal/components/icons";
import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel";
import "@portal/components/SettingsModal.css"; import "@portal/components/SettingsModal.css";
type SettingsSection = type SettingsSection =
@@ -41,7 +43,21 @@ type SettingsSection =
| "general" | "general"
| "authentication" | "authentication"
| "sessions" | "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. */ /** Org-wide auth posture the Admin sections edit, mirrored into local state. */
interface SecurityForm { interface SecurityForm {
@@ -54,6 +70,12 @@ interface SecurityForm {
interface SettingsModalProps { interface SettingsModalProps {
open: boolean; open: boolean;
onClose: () => void; 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 * 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. * 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 { t } = useTranslation();
const { tier } = useTier(); const { tier } = useTier();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -124,6 +150,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
{ {
title: t("settings.groups.admin"), title: t("settings.groups.admin"),
items: [ items: [
{
key: "account-link",
label: t("settings.sections.account-link"),
icon: <LinkIcon size={16} />,
},
{ {
key: "authentication", key: "authentication",
label: t("settings.sections.authentication"), label: t("settings.sections.authentication"),
@@ -189,8 +220,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
}, [snapshot]); }, [snapshot]);
useEffect(() => { useEffect(() => {
if (open) setSection("profile"); if (!open) return;
}, [open]); const requested = initialSection ?? null;
setSection(isSettingsSection(requested) ? requested : "profile");
}, [open, initialSection]);
const regionOptions = useMemo<SelectOption[]>(() => { const regionOptions = useMemo<SelectOption[]>(() => {
if (!snapshot) return []; if (!snapshot) return [];
@@ -301,6 +334,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
} }
/> />
)} )}
{section === "account-link" && <AccountLinkPanel />}
</SettingsShell> </SettingsShell>
</Modal> </Modal>
); );
+26 -2
View File
@@ -4,6 +4,7 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useTier } from "@portal/contexts/TierContext"; import { useTier } from "@portal/contexts/TierContext";
import { useTheme } from "@portal/contexts/ThemeContext"; import { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext"; import { useUI } from "@portal/contexts/UIContext";
import { useLink } from "@portal/contexts/LinkContext";
import { useAsync } from "@portal/hooks/useAsync"; import { useAsync } from "@portal/hooks/useAsync";
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
import { EDITOR_URL } from "@portal/auth/editorUrl"; import { EDITOR_URL } from "@portal/auth/editorUrl";
@@ -19,6 +20,7 @@ import {
ComponentsIcon, ComponentsIcon,
InfrastructureIcon, InfrastructureIcon,
UsageIcon, UsageIcon,
LinkIcon,
DocsIcon, DocsIcon,
SettingsIcon, SettingsIcon,
ChevronDownIcon, ChevronDownIcon,
@@ -47,6 +49,27 @@ const GROUP_PLATFORM: NavEntry[] = [
{ id: "docs", icon: <DocsIcon /> }, { id: "docs", icon: <DocsIcon /> },
]; ];
/**
* 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 (
<NavItem
id="account-link"
label={t("shell.sidebar.linkAccount", "Link Stirling account")}
icon={<LinkIcon />}
onClick={() => openLinkModal()}
/>
);
}
function UsageFooter() { function UsageFooter() {
const { tier } = useTier(); const { tier } = useTier();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -90,8 +113,8 @@ function UsageFooter() {
const planLabel = const planLabel =
tier === "pro" tier === "pro"
? t("shell.sidebar.planPayAsYouGo") ? t("shell.sidebar.planProcessor", "Processor plan")
: t("shell.sidebar.planEnterprise"); : t("shell.sidebar.planEnterprise", "Enterprise plan");
return ( return (
<div className="portal-sidebar__usage"> <div className="portal-sidebar__usage">
@@ -200,6 +223,7 @@ export function Sidebar() {
</nav> </nav>
<div className="portal-sidebar__footer"> <div className="portal-sidebar__footer">
<LinkAccountFooterItem />
<NavItem <NavItem
id="settings" id="settings"
label={t("nav.settings")} label={t("nav.settings")}
@@ -0,0 +1,120 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Skeleton, StatusBadge } from "@shared/components";
import { useAsync } from "@portal/hooks/useAsync";
import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext";
import { useLink, LINK_INFO } from "@portal/contexts/LinkContext";
import { HttpError } from "@portal/api/http";
import {
fetchInstances,
revokeInstance as apiRevokeInstance,
type LinkedInstanceRow,
} from "@portal/api/link";
import { LinkAccountCard } from "@portal/components/account-link/LinkAccountCard";
import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable";
import "@portal/views/AccountLink.css";
/**
* Account-link surface rendered inside the Settings modal (Admin group). Same
* content as the former /account-link view: the LinkAccountCard for THIS
* instance + the team-wide LinkedInstancesTable. Lives inline so admins find it
* intentionally rather than via a top-level sidebar nav entry.
*/
export function AccountLinkPanel() {
const { t } = useTranslation();
const link = useAccountLinkContext();
const { linkState } = useLink();
const linked = link.status?.linked ?? false;
const [reloadKey, setReloadKey] = useState(0);
// Only fetch the team-wide instance list when THIS instance is linked. When
// unlinked, the portal has no team to display — the admin's SaaS session may
// still be valid in the browser, but the local instance isn't part of a team
// (so showing the team's other instances would be confusing).
const instancesState = useAsync<LinkedInstanceRow[]>(
() => (linked ? fetchInstances() : Promise.resolve([])),
[reloadKey, linked],
);
const [revokingId, setRevokingId] = useState<number | null>(null);
const [revokeError, setRevokeError] = useState<string | null>(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 (
<div className="portal-link portal-link--in-settings">
<header className="portal-link__header">
<div>
<p className="portal-link__page-sub">{t("accountLink.panel.sub")}</p>
</div>
<StatusBadge
tone={
linkState === "linked-subscribed"
? "success"
: linkState === "linked-free"
? "info"
: "neutral"
}
size="md"
>
{t(LINK_INFO[linkState].labelKey)}
</StatusBadge>
</header>
<LinkAccountCard link={link} />
{linked && (
<section className="portal-link__instances">
<div className="portal-link__section-head">
<h2 className="portal-link__section-title">
{t("accountLink.panel.instancesTitle")}
</h2>
<p className="portal-link__section-sub">
{t("accountLink.panel.instancesSub")}
</p>
</div>
{instancesState.loading ? (
<div className="portal-link__skeleton" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
) : instancesState.error ? (
<Banner
tone="danger"
title={t("accountLink.panel.loadError.title")}
>
{instancesState.error instanceof HttpError &&
instancesState.error.status === 403
? t("accountLink.panel.loadError.forbidden")
: t("accountLink.panel.loadError.generic")}
</Banner>
) : (
<LinkedInstancesTable
instances={instancesState.data ?? []}
onRevoke={revoke}
revokingId={revokingId}
/>
)}
{revokeError && (
<Banner tone="danger" title={t("accountLink.panel.revokeError")}>
{revokeError}
</Banner>
)}
</section>
)}
</div>
);
}
@@ -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<typeof LinkAccountCard> = {
title: "Portal/AccountLink/LinkAccountCard",
component: LinkAccountCard,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof LinkAccountCard>;
/** 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.",
},
},
};
@@ -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 (
<Card padding="loose" className="portal-link__card">
<div className="portal-link__card-head">
<div>
<span className="portal-link__eyebrow">
{t("accountLink.card.eyebrow")}
</span>
<h2 className="portal-link__title">{t("accountLink.card.title")}</h2>
</div>
<StatusBadge tone={linked ? "success" : "neutral"} size="sm">
{linked
? t("accountLink.card.linked")
: t("accountLink.card.notLinked")}
</StatusBadge>
</div>
{!link.loginConfigured && (
<Banner
tone="neutral"
title={t("accountLink.card.loginNotConfigured.title")}
>
{t("accountLink.card.loginNotConfigured.before")}{" "}
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
{t("accountLink.card.loginNotConfigured.after")}
</Banner>
)}
{link.error && (
<Banner tone="danger" title={t("accountLink.card.error.title")}>
{link.error}
</Banner>
)}
{linked ? (
<div className="portal-link__actions">
<span className="portal-link__muted">
{link.status?.name
? t("accountLink.card.linkedAs", { name: link.status.name })
: t("accountLink.card.linkedGeneric")}{" "}
{t("accountLink.card.billingNote")}
</span>
<Button
variant="outline"
accent="red"
loading={linking}
onClick={link.unlink}
>
{t("accountLink.card.unlink")}
</Button>
</div>
) : (
<div className="portal-link__actions">
<Button loading={linking} onClick={() => openLinkModal()}>
{t("accountLink.card.linkButton")}
</Button>
</div>
)}
</Card>
);
}
@@ -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<void>;
}
/**
* 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 (
<Modal
open={open}
onClose={onClose}
width="md"
title={
reauth
? t("accountLink.modal.reauthTitle")
: t("accountLink.modal.linkTitle")
}
subtitle={
reauth
? t("accountLink.modal.reauthSubtitle")
: t("accountLink.modal.linkSubtitle")
}
>
{isSaasSupabaseConfigured ? (
<SupabaseLoginForm state={login} />
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<Banner
tone="neutral"
title={t("accountLink.modal.loginNotConfigured.title")}
>
{t("accountLink.modal.loginNotConfigured.before")}{" "}
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
{t("accountLink.modal.loginNotConfigured.and")}{" "}
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
{t("accountLink.modal.loginNotConfigured.after")}
</Banner>
{import.meta.env.DEV && (
<Button
variant="outline"
onClick={async () => {
await onLinked({ access_token: "dev-stub-jwt" });
onClose();
}}
>
{t("accountLink.modal.simulateSignIn")}
</Button>
)}
</div>
)}
</Modal>
);
}
@@ -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<typeof LinkGate> = {
title: "Portal/AccountLink/LinkGate",
component: LinkGate,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof LinkGate>;
/**
* 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: (
<Card padding="loose">A billable feature, unlocked once linked.</Card>
),
},
};
@@ -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 (
<Banner
tone="info"
title={
feature
? t("accountLink.gate.titleFeature", { feature })
: t("accountLink.gate.title")
}
description={t("accountLink.gate.description")}
action={
<Button size="sm" onClick={() => openLinkModal()}>
{t("accountLink.gate.action")}
</Button>
}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More