diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml
index 2be287bd0f..77c9645476 100644
--- a/.taskfiles/backend.yml
+++ b/.taskfiles/backend.yml
@@ -30,6 +30,10 @@ tasks:
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
+ # `dotenv:` reads from the root Taskfile's directory (".") because this
+ # subtaskfile is included with `dir: .`. Local overrides in
+ # .env.proprietary.local win over the committed .env.proprietary defaults.
+ dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
diff --git a/app/.env.proprietary b/app/.env.proprietary
new file mode 100644
index 0000000000..4e30318e57
--- /dev/null
+++ b/app/.env.proprietary
@@ -0,0 +1,8 @@
+# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
+# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
+
+# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
+# Flip to true in app/.env.proprietary.local to test linking locally.
+STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
+# SaaS base URL the linked instance calls (register + entitlement).
+STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
diff --git a/app/.gitignore b/app/.gitignore
index e2a86ce4cf..f7e1a1575a 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,3 +1,4 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
+!.env.proprietary
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java
new file mode 100644
index 0000000000..a77f67f8a8
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java
@@ -0,0 +1,265 @@
+package stirling.software.proprietary.accountlink;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
+ * A").
+ *
+ *
Two calls:
+ *
+ *
+ *
{@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.
+ *
{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
+ * GET /api/v1/instance/entitlement}; what the local gate consults.
+ *
+ *
+ *
Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
+ * {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
+ * endpoint.
+ */
+@Slf4j
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class AccountLinkClient {
+
+ static final String HEADER_DEVICE_ID = "X-Device-Id";
+ static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
+
+ private final AccountLinkProperties properties;
+ private final ObjectMapper mapper;
+ private final HttpClient httpClient;
+
+ @Autowired
+ public AccountLinkClient(AccountLinkProperties properties, ObjectMapper mapper) {
+ this(
+ properties,
+ mapper,
+ HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(properties.getRequestTimeoutSeconds()))
+ .build());
+ }
+
+ /** Package-private: lets tests inject a stub {@link HttpClient}. */
+ AccountLinkClient(
+ AccountLinkProperties properties, ObjectMapper mapper, HttpClient httpClient) {
+ this.properties = properties;
+ this.mapper = mapper;
+ this.httpClient = httpClient;
+ }
+
+ /** The device credential a successful {@link #register} returns. */
+ public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
+
+ /**
+ * A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
+ * map auth failures (401/403) through rather than masking everything as a 502.
+ */
+ public static class UpstreamException extends IOException {
+ private final int status;
+
+ public UpstreamException(int status, String body) {
+ super("SaaS account-link returned HTTP " + status + ": " + body);
+ this.status = status;
+ }
+
+ public int status() {
+ return status;
+ }
+ }
+
+ /**
+ * Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
+ * or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
+ * open): the cache must BLOCK billable work on this rather than serve a stale entitled
+ * snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
+ * try/catch.
+ */
+ public static final class RevokedException extends RuntimeException {
+ private final int status;
+
+ public RevokedException(int status) {
+ super("SaaS entitlement denied (credential revoked/invalid): HTTP " + status);
+ this.status = status;
+ }
+
+ public int status() {
+ return status;
+ }
+ }
+
+ /**
+ * Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
+ * credential.
+ *
+ * @throws IOException on transport failure or a non-2xx response (caller surfaces to the
+ * admin).
+ */
+ public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
+ String body =
+ instanceName == null || instanceName.isBlank()
+ ? "{}"
+ : "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(uri("/api/v1/account-link/register"))
+ .header("Authorization", "Bearer " + supabaseJwt)
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .timeout(timeout())
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build();
+
+ HttpResponse response = send(request);
+ if (response.statusCode() / 100 != 2) {
+ throw new UpstreamException(response.statusCode(), response.body());
+ }
+ JsonNode root = mapper.readTree(response.body());
+ String deviceId = text(root, "deviceId");
+ String deviceSecret = text(root, "deviceSecret");
+ if (deviceId == null || deviceSecret == null) {
+ throw new IOException("SaaS register response missing deviceId/deviceSecret");
+ }
+ Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
+ return new RegisterResult(deviceId, deviceSecret, teamId);
+ }
+
+ /**
+ * Revokes this instance's own credential on the SaaS side ({@code POST
+ * /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
+ * allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
+ * or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
+ * row for follow-up. Idempotent on SaaS (already-revoked → still 204).
+ */
+ public boolean revokeSelf(String deviceId, String deviceSecret) {
+ try {
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(uri("/api/v1/instance/revoke-self"))
+ .header(HEADER_DEVICE_ID, deviceId)
+ .header(HEADER_DEVICE_SECRET, deviceSecret)
+ .header("Accept", "application/json")
+ .timeout(timeout())
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .build();
+ HttpResponse response = send(request);
+ if (response.statusCode() / 100 != 2) {
+ log.debug("Self-revoke returned HTTP {}", response.statusCode());
+ return false;
+ }
+ return true;
+ } catch (Exception e) {
+ log.debug("Self-revoke failed: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Fetches the current entitlement using the stored device credential. Three outcomes:
+ *
+ *
+ *
2xx → the parsed snapshot.
+ *
401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
+ * the caller must BLOCK, not fail open.
+ *
transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
+ * ("unknown" — the caller fails open).
+ *
The portal (served from this same origin, admin authenticated by the existing self-hosted
+ * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
+ * backend, which mints + returns a device credential we store locally. {@code GET /status} backs
+ * the portal's link card.
+ *
+ *
Admin-only, {@code @Profile("!saas")}, gated behind {@code
+ * stirling.billing.account-link.enabled} — off → bean absent → 404.
+ */
+@Slf4j
+@Hidden
+@RestController
+@RequestMapping("/api/v1/account-link")
+@Profile("!saas")
+@PreAuthorize("hasRole('ADMIN')")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class AccountLinkController {
+
+ private final AccountLinkService service;
+
+ public AccountLinkController(AccountLinkService service) {
+ this.service = service;
+ }
+
+ /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
+ public record LinkRequest(String supabaseJwt, String name) {}
+
+ @PostMapping("/link")
+ public ResponseEntity> link(@RequestBody LinkRequest req) {
+ if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
+ return ResponseEntity.badRequest()
+ .body(java.util.Map.of("error", "supabaseJwt is required"));
+ }
+ try {
+ return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
+ } catch (AccountLinkClient.UpstreamException e) {
+ // Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
+ // the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the
+ // raw upstream body back to the browser.
+ HttpStatus status =
+ e.status() == HttpStatus.UNAUTHORIZED.value()
+ || e.status() == HttpStatus.FORBIDDEN.value()
+ ? HttpStatus.valueOf(e.status())
+ : HttpStatus.BAD_GATEWAY;
+ log.warn("Account-link register rejected upstream: HTTP {}", e.status());
+ return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
+ } catch (IOException e) {
+ // Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
+ // configured SaaS host/IP. Log it server-side; return the same opaque body the
+ // UpstreamException branch does.
+ log.warn("Account-link failed (transport): {}", e.getMessage());
+ return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
+ .body(java.util.Map.of("error", "LINK_FAILED"));
+ }
+ }
+
+ @GetMapping("/status")
+ public ResponseEntity status() {
+ return ResponseEntity.ok(service.status());
+ }
+
+ @PostMapping("/unlink")
+ public ResponseEntity unlink() {
+ service.unlink();
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java
new file mode 100644
index 0000000000..c12e56f06d
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java
@@ -0,0 +1,39 @@
+package stirling.software.proprietary.accountlink;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * Self-hosted side of combined-billing "Mode A" (connected self-hosted).
+ *
+ *
Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
+ * the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
+ * code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
+ * off by default and dark — when off nothing gates and the link endpoints 404.
+ */
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "stirling.billing.account-link")
+public class AccountLinkProperties {
+
+ /** Master switch. When {@code false} (default) the feature is fully inert. */
+ private boolean enabled = false;
+
+ /**
+ * Base URL of the SaaS backend this instance links to (register + entitlement live there).
+ *
+ *
STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
+ * existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
+ */
+ private String saasBaseUrl = "https://stirling.com/app";
+
+ /** Cached entitlement is reused for this long before a refresh is attempted. */
+ private long entitlementCacheSeconds = 300;
+
+ /** Connect/read timeout for the outbound SaaS calls. */
+ private int requestTimeoutSeconds = 10;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java
new file mode 100644
index 0000000000..1bb27d9cd6
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java
@@ -0,0 +1,92 @@
+package stirling.software.proprietary.accountlink;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Linking orchestrator (self-hosted side of combined-billing "Mode A").
+ *
+ *
{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
+ * JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
+ * The credential — not the JWT — authenticates all later unattended entitlement calls.
+ */
+@Slf4j
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class AccountLinkService {
+
+ private final AccountLinkClient client;
+ private final DeviceCredentialStore credentialStore;
+ private final EntitlementCache entitlementCache;
+
+ public AccountLinkService(
+ AccountLinkClient client,
+ DeviceCredentialStore credentialStore,
+ EntitlementCache entitlementCache) {
+ this.client = client;
+ this.credentialStore = credentialStore;
+ this.entitlementCache = entitlementCache;
+ }
+
+ /** Status of this instance's link, for the portal's "Account link" card. */
+ public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
+
+ /**
+ * Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
+ * credential.
+ *
+ * @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
+ */
+ public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
+ AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
+ credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
+ entitlementCache.invalidate();
+ log.info("Account-link: instance linked to team {}", result.teamId());
+ return status();
+ }
+
+ /**
+ * Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code
+ * revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
+ * still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
+ */
+ public void unlink() {
+ credentialStore
+ .get()
+ .ifPresent(
+ c -> {
+ boolean ok = client.revokeSelf(c.getDeviceId(), c.getDeviceSecret());
+ if (!ok) {
+ log.warn(
+ "Account-link: SaaS self-revoke failed for device {};"
+ + " clearing locally anyway (admin can revoke"
+ + " from the portal).",
+ c.getDeviceId());
+ }
+ });
+ credentialStore.clear();
+ entitlementCache.invalidate();
+ log.info("Account-link: instance unlinked");
+ }
+
+ public LinkStatus status() {
+ Optional cred = credentialStore.get();
+ return cred.map(
+ c ->
+ new LinkStatus(
+ true,
+ c.getDeviceId(),
+ c.getTeamId(),
+ c.getLinkedAt() != null
+ ? c.getLinkedAt().toString()
+ : null))
+ .orElseGet(() -> new LinkStatus(false, null, null, null));
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java
new file mode 100644
index 0000000000..8c20abfa11
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkWebMvcConfig.java
@@ -0,0 +1,36 @@
+package stirling.software.proprietary.accountlink;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Registers the account-link entitlement gate. Path patterns cover the billable API surface; the
+ * interceptor itself re-checks billability (and short-circuits manual tools), but scoping here
+ * keeps the gate off the bulk of interactive endpoints entirely.
+ *
+ *
Whole config is gated behind {@code stirling.billing.account-link.enabled} +
+ * {@code @Profile("!saas")}; absent when off, so no interceptor is registered.
+ */
+@Configuration
+@Profile("!saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class AccountLinkWebMvcConfig implements WebMvcConfigurer {
+
+ private final InstanceEntitlementInterceptor gateInterceptor;
+
+ public AccountLinkWebMvcConfig(InstanceEntitlementInterceptor gateInterceptor) {
+ this.gateInterceptor = gateInterceptor;
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ // AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual
+ // calls be gated too, while the interceptor lets genuine manual tools through.
+ registry.addInterceptor(gateInterceptor)
+ .addPathPatterns("/api/v1/**")
+ .excludePathPatterns("/api/v1/account-link/**");
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java
new file mode 100644
index 0000000000..136e4c3214
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java
@@ -0,0 +1,38 @@
+package stirling.software.proprietary.accountlink;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import stirling.software.common.service.InternalApiClient;
+
+/**
+ * Classifies a request as billable (AI / automation) or free (a manual tool).
+ *
+ *
Mirrors the saas billing categorisation at a coarse level, without depending on the saas
+ * module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
+ * marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
+ * sub-steps). Everything else — interactive manual PDF tools — is always free.
+ */
+public final class BillableOperationClassifier {
+
+ private static final String AI_PATH_PREFIX = "/api/v1/ai/";
+
+ private BillableOperationClassifier() {}
+
+ public static boolean isBillable(HttpServletRequest request) {
+ if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
+ return true;
+ }
+ String uri = request.getRequestURI();
+ if (uri == null) {
+ return false;
+ }
+ // Prefix-match the AI surface (not a loose substring contains), stripping a deployment
+ // context path so //api/v1/ai/** still classifies as billable.
+ String ctx = request.getContextPath();
+ String path =
+ ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
+ ? uri.substring(ctx.length())
+ : uri;
+ return path.startsWith(AI_PATH_PREFIX);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java
new file mode 100644
index 0000000000..4625572310
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java
@@ -0,0 +1,53 @@
+package stirling.software.proprietary.accountlink;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * The device credential this self-hosted instance received when it linked a SaaS account
+ * (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team.
+ *
+ *
Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
+ * deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
+ * database (the same store that already holds API-key material and the license signature), so it is
+ * as secure-at-rest as the rest of the instance's secrets.
+ */
+@Entity
+@Table(name = "account_link_device_credential")
+@NoArgsConstructor
+@Getter
+@Setter
+public class DeviceCredential implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final Long SINGLETON_ID = 1L;
+
+ @Id
+ @Column(name = "id")
+ private Long id = SINGLETON_ID;
+
+ /** Public identifier minted by the SaaS register call; sent as {@code X-Device-Id}. */
+ @Column(name = "device_id", nullable = false, length = 64)
+ private String deviceId;
+
+ /** High-entropy secret returned once by register; sent as {@code X-Device-Secret}. */
+ @Column(name = "device_secret", nullable = false, length = 128)
+ private String deviceSecret;
+
+ /** SaaS team this instance is linked to; informational on the instance side. */
+ @Column(name = "team_id")
+ private Long teamId;
+
+ @Column(name = "linked_at", nullable = false)
+ private LocalDateTime linkedAt;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java
new file mode 100644
index 0000000000..792731e589
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialRepository.java
@@ -0,0 +1,15 @@
+package stirling.software.proprietary.accountlink;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface DeviceCredentialRepository extends JpaRepository {
+
+ /** The singleton credential, if this instance has linked. */
+ default Optional findCredential() {
+ return findById(DeviceCredential.SINGLETON_ID);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java
new file mode 100644
index 0000000000..33658a48c6
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredentialStore.java
@@ -0,0 +1,55 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Secure-at-rest persistence for this instance's device credential. Thin wrapper over the
+ * singleton-row repository so the rest of the feature never touches JPA directly.
+ *
+ *
Gated + {@code @Profile("!saas")}: only the self-hosted profile links outward to a SaaS team.
+ */
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class DeviceCredentialStore {
+
+ private final DeviceCredentialRepository repo;
+
+ public DeviceCredentialStore(DeviceCredentialRepository repo) {
+ this.repo = repo;
+ }
+
+ @Transactional(readOnly = true)
+ public Optional get() {
+ return repo.findCredential();
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isLinked() {
+ return repo.findCredential().isPresent();
+ }
+
+ /** Persists (or replaces) the credential returned by a SaaS register call. */
+ @Transactional
+ public void save(String deviceId, String deviceSecret, Long teamId) {
+ DeviceCredential cred = repo.findCredential().orElseGet(DeviceCredential::new);
+ cred.setId(DeviceCredential.SINGLETON_ID);
+ cred.setDeviceId(deviceId);
+ cred.setDeviceSecret(deviceSecret);
+ cred.setTeamId(teamId);
+ cred.setLinkedAt(LocalDateTime.now());
+ repo.save(cred);
+ }
+
+ /** Unlinks this instance locally (idempotent). */
+ @Transactional
+ public void clear() {
+ repo.findCredential().ifPresent(repo::delete);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java
new file mode 100644
index 0000000000..63818c1f98
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java
@@ -0,0 +1,124 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
+ * every billable request. Single-slot (one instance = one linked team), TTL-based.
+ *
+ *
Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
+ * has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
+ * ever been fetched and the latest refresh failed (the gate treats empty as "unknown →
+ * allow").
+ *
+ *
But an AUTHORITATIVE deny (revoked/invalid credential → {@link
+ * AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
+ * {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
+ * rather than serving a stale entitled snapshot.
+ */
+@Slf4j
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class EntitlementCache {
+
+ private final DeviceCredentialStore credentialStore;
+ private final AccountLinkClient client;
+ private final Duration ttl;
+
+ /** Entitlement + fetch time, swapped atomically as one value so readers never tear. */
+ private record Snapshot(InstanceEntitlement entitlement, Instant fetchedAt) {}
+
+ private static final Snapshot EMPTY = new Snapshot(null, Instant.EPOCH);
+
+ /** Blocked entitlement synthesised on an authoritative deny (revoked/invalid credential). */
+ private static final InstanceEntitlement REVOKED =
+ new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
+
+ private volatile Snapshot snapshot = EMPTY;
+
+ /** Single-flight guard: one thread refreshes while others serve the current snapshot. */
+ private final AtomicBoolean refreshing = new AtomicBoolean(false);
+
+ public EntitlementCache(
+ DeviceCredentialStore credentialStore,
+ AccountLinkClient client,
+ AccountLinkProperties properties) {
+ this.credentialStore = credentialStore;
+ this.client = client;
+ this.ttl = Duration.ofSeconds(properties.getEntitlementCacheSeconds());
+ }
+
+ /**
+ * Current entitlement, refreshing if stale. {@link Optional#empty()} means "unknown" — either
+ * not linked or the SaaS side is unreachable and we have no prior snapshot.
+ */
+ public Optional current() {
+ // Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
+ // call) while concurrent callers serve the last snapshot — no thundering herd of
+ // synchronous round-trips on the billable hot path. Safe because the gate fails open.
+ if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
+ try {
+ refresh();
+ } finally {
+ refreshing.set(false);
+ }
+ }
+ return Optional.ofNullable(snapshot.entitlement());
+ }
+
+ private boolean isStale(Snapshot snap) {
+ // fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
+ // fetch backs off for a full TTL instead of every billable request re-triggering a
+ // blocking round-trip against a dead/slow SaaS endpoint.
+ return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
+ }
+
+ /**
+ * Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
+ * still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
+ * (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
+ */
+ void refresh() {
+ Optional cred = credentialStore.get();
+ if (cred.isEmpty()) {
+ // Unlinked: clear any stale snapshot so the gate sees "not linked".
+ snapshot = new Snapshot(null, Instant.now());
+ return;
+ }
+ try {
+ InstanceEntitlement fresh =
+ client.fetchEntitlement(cred.get().getDeviceId(), cred.get().getDeviceSecret());
+ if (fresh != null) {
+ snapshot = new Snapshot(fresh, Instant.now());
+ } else {
+ // Unreachable / server error: keep the last known entitlement (may be null) but
+ // stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
+ log.debug(
+ "Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
+ snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
+ }
+ } catch (AccountLinkClient.RevokedException e) {
+ // Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately
+ // rather than serving the stale entitled snapshot until the next unlink.
+ log.info(
+ "Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
+ e.status());
+ snapshot = new Snapshot(REVOKED, Instant.now());
+ }
+ }
+
+ /** Forces a refresh on the next {@link #current()} (e.g. right after linking). */
+ public void invalidate() {
+ snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java
new file mode 100644
index 0000000000..1cca34cb69
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementState.java
@@ -0,0 +1,19 @@
+package stirling.software.proprietary.accountlink;
+
+/**
+ * Coarse entitlement state the local gate enforces against. Proprietary-local (no coupling to the
+ * saas billing module): the SaaS entitlement response is parsed into this minimal shape.
+ */
+public enum EntitlementState {
+ /** Within free pool or covered by an active subscription — billable work allowed. */
+ OK,
+ /** Free pool exhausted and no subscription / over the period cap — billable work blocked. */
+ OVER_LIMIT,
+ /**
+ * Device credential revoked/invalid on the SaaS side (authoritative 401/403 deny) — billable
+ * work blocked. Synthesised locally by {@code EntitlementCache}, never sent by SaaS.
+ */
+ REVOKED,
+ /** Unrecognised/malformed reply — the gate falls back to its numeric checks, not this flag. */
+ UNKNOWN
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java
new file mode 100644
index 0000000000..677183278b
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java
@@ -0,0 +1,34 @@
+package stirling.software.proprietary.accountlink;
+
+/**
+ * Outcome of {@link InstanceEntitlementGate}. {@link #allowed} is what the interceptor enforces;
+ * {@link #reason} carries the machine-readable signal the FE maps to a prompt (e.g. "link to
+ * activate"). Manual-tool and fail-open allows carry an informational reason but never block.
+ */
+public record GateDecision(boolean allowed, Reason reason) {
+
+ public enum Reason {
+ /** Feature flag is off — gate is fully inert. */
+ FLAG_OFF,
+ /** Operation is a manual tool — always free, never gated. */
+ MANUAL_FREE,
+ /** Linked + within entitlement — billable work allowed. */
+ ENTITLED,
+ /** Entitlement source unreachable — fail open, allow. */
+ FAIL_OPEN,
+ /** Not linked — block billable work; FE should prompt to link. */
+ NOT_LINKED,
+ /** Linked but over the limit / no subscription — block billable work. */
+ OVER_LIMIT,
+ /** Credential revoked/invalid on the SaaS side — block billable work. */
+ REVOKED
+ }
+
+ public static GateDecision allow(Reason reason) {
+ return new GateDecision(true, reason);
+ }
+
+ public static GateDecision block(Reason reason) {
+ return new GateDecision(false, reason);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java
new file mode 100644
index 0000000000..6445d886e9
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java
@@ -0,0 +1,19 @@
+package stirling.software.proprietary.accountlink;
+
+/**
+ * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response —
+ * just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
+ * saas types.
+ *
+ * @param subscribed team has an active subscription
+ * @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
+ * @param periodSpendUnits paid units spent this period
+ * @param periodCapUnits paid cap for the period; {@code null} = uncapped
+ * @param state coarse state classification (see {@link EntitlementState})
+ */
+public record InstanceEntitlement(
+ boolean subscribed,
+ long freeRemainingUnits,
+ long periodSpendUnits,
+ Long periodCapUnits,
+ EntitlementState state) {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java
new file mode 100644
index 0000000000..c975bad055
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java
@@ -0,0 +1,104 @@
+package stirling.software.proprietary.accountlink;
+
+import java.util.Optional;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+/**
+ * Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
+ *
+ *
Rules (in order):
+ *
+ *
+ *
Flag off → always allow (feature inert).
+ *
Manual tool → always allow (manual tools are free, never metered).
+ *
Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
+ *
Billable + linked + over limit → block with {@code OVER_LIMIT}.
+ *
+ *
+ *
The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
+ * live flag / linked-state / entitlement. This is the unit-tested core.
+ */
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class InstanceEntitlementGate {
+
+ private final AccountLinkProperties properties;
+ private final DeviceCredentialStore credentialStore;
+ private final EntitlementCache entitlementCache;
+
+ public InstanceEntitlementGate(
+ AccountLinkProperties properties,
+ DeviceCredentialStore credentialStore,
+ EntitlementCache entitlementCache) {
+ this.properties = properties;
+ this.credentialStore = credentialStore;
+ this.entitlementCache = entitlementCache;
+ }
+
+ /** Evaluates the gate for a request, resolving live state from the store + cache. */
+ public GateDecision evaluate(boolean billable) {
+ if (!properties.isEnabled()) {
+ return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
+ }
+ if (!billable) {
+ return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
+ }
+ boolean linked = credentialStore.isLinked();
+ Optional entitlement =
+ linked ? entitlementCache.current() : Optional.empty();
+ return decide(true, true, linked, entitlement);
+ }
+
+ /**
+ * Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
+ * (unreachable): when linked, that fails open.
+ */
+ public static GateDecision decide(
+ boolean flagEnabled,
+ boolean billable,
+ boolean linked,
+ Optional entitlement) {
+ if (!flagEnabled) {
+ return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
+ }
+ if (!billable) {
+ return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
+ }
+ if (!linked) {
+ return GateDecision.block(GateDecision.Reason.NOT_LINKED);
+ }
+ if (entitlement.isEmpty()) {
+ // Linked but entitlement source unreachable — never hard-block billable work on our
+ // inability to reach billing.
+ return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
+ }
+ InstanceEntitlement e = entitlement.get();
+ if (e.state() == EntitlementState.REVOKED) {
+ // Credential revoked/invalid (authoritative deny) — block, distinct from over-limit.
+ return GateDecision.block(GateDecision.Reason.REVOKED);
+ }
+ return entitled(e)
+ ? GateDecision.allow(GateDecision.Reason.ENTITLED)
+ : GateDecision.block(GateDecision.Reason.OVER_LIMIT);
+ }
+
+ /** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
+ private static boolean entitled(InstanceEntitlement e) {
+ if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
+ return false;
+ }
+ if (e.subscribed()) {
+ // Subscribed: allowed unless a period cap is set and exceeded.
+ return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
+ }
+ // Unsubscribed: only the free pool covers billable work.
+ return e.freeRemainingUnits() > 0;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java
new file mode 100644
index 0000000000..8597813a85
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java
@@ -0,0 +1,65 @@
+package stirling.software.proprietary.accountlink;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
+ * blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
+ *
+ *
Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
+ * {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
+ * activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
+ * responses). Fail-open and flag-off both let the request continue.
+ *
+ *
A self-hosted instance's local backend calls {@code POST /register} with the admin's
+ * short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new
+ * auth here). We resolve the caller's team, mint a device credential bound to it, and return the
+ * secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
+ * JWT.
+ *
+ *
Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent →
+ * 404. Leader-only, and the team is always derived from the caller (never the request body).
+ */
+@Slf4j
+@Hidden
+@RestController
+@RequestMapping("/api/v1/account-link")
+@Profile("saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class AccountLinkController {
+
+ private final AccountLinkService service;
+ private final TeamMembershipRepository memberRepo;
+ private final UserRepository userRepository;
+
+ public AccountLinkController(
+ AccountLinkService service,
+ TeamMembershipRepository memberRepo,
+ UserRepository userRepository) {
+ this.service = service;
+ this.memberRepo = memberRepo;
+ this.userRepository = userRepository;
+ }
+
+ /** Optional display name for the instance (hostname / label). */
+ public record RegisterRequest(String name) {}
+
+ /** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
+ public record RegisterResponse(
+ Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
+
+ public record InstanceRow(
+ Long instanceId,
+ String deviceId,
+ String name,
+ String createdAt,
+ String lastSeenAt,
+ boolean revoked) {}
+
+ @PostMapping("/register")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity register(
+ @RequestBody(required = false) RegisterRequest req, Authentication auth) {
+ LeaderTeam lt = resolveLeaderTeam(auth);
+ if (lt.error() != null) {
+ return ResponseEntity.status(lt.error()).build();
+ }
+ String name = req != null ? req.name() : null;
+ AccountLinkService.RegisteredInstance reg =
+ service.register(lt.teamId(), lt.userId(), name);
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(
+ new RegisterResponse(
+ reg.instanceId(),
+ lt.teamId(),
+ reg.deviceId(),
+ reg.deviceSecret(),
+ reg.name()));
+ }
+
+ @GetMapping("/instances")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity> list(Authentication auth) {
+ LeaderTeam lt = resolveLeaderTeam(auth);
+ if (lt.error() != null) {
+ return ResponseEntity.status(lt.error()).build();
+ }
+ List rows =
+ service.list(lt.teamId()).stream()
+ .map(
+ i ->
+ new InstanceRow(
+ i.getInstanceId(),
+ i.getDeviceId(),
+ i.getName(),
+ i.getCreatedAt() != null
+ ? i.getCreatedAt().toString()
+ : null,
+ i.getLastSeenAt() != null
+ ? i.getLastSeenAt().toString()
+ : null,
+ i.getRevokedAt() != null))
+ .toList();
+ return ResponseEntity.ok(rows);
+ }
+
+ @PostMapping("/instances/{instanceId}/revoke")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity revoke(@PathVariable Long instanceId, Authentication auth) {
+ LeaderTeam lt = resolveLeaderTeam(auth);
+ if (lt.error() != null) {
+ return ResponseEntity.status(lt.error()).build();
+ }
+ boolean ok = service.revoke(lt.teamId(), instanceId);
+ return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Helpers — team always derived from the caller; instance linking is a leader (billing) action.
+ // ---------------------------------------------------------------------------------------
+
+ /**
+ * Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
+ */
+ private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
+
+ private LeaderTeam resolveLeaderTeam(Authentication auth) {
+ User user;
+ try {
+ user = AuthenticationUtils.getCurrentUser(auth, userRepository);
+ } catch (SecurityException e) {
+ return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
+ }
+ List rows = memberRepo.findPrimaryMembership(user.getId());
+ if (rows.isEmpty()) {
+ return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
+ }
+ TeamMembership m = rows.get(0);
+ if (m.getRole() != TeamRole.LEADER) {
+ return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
+ }
+ return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java
new file mode 100644
index 0000000000..c31fc1b03e
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java
@@ -0,0 +1,117 @@
+package stirling.software.saas.accountlink;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.LocalDateTime;
+import java.util.Base64;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Account-link instance registration + lifecycle (combined-billing "Mode A").
+ *
+ *
Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
+ * to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
+ * unattended entitlement reads with that credential.
+ *
+ *
Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
+ * {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
+ */
+@Slf4j
+@Service
+@Profile("saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class AccountLinkService {
+
+ /** 32 bytes of entropy → URL-safe secret; high enough that an unsalted SHA-256 hash is fine. */
+ private static final int SECRET_BYTES = 32;
+
+ private final LinkedInstanceRepository repo;
+ private final SecureRandom random = new SecureRandom();
+
+ public AccountLinkService(LinkedInstanceRepository repo) {
+ this.repo = repo;
+ }
+
+ /** Result of {@link #register}; {@code deviceSecret} is plaintext and returned exactly once. */
+ public record RegisteredInstance(
+ Long instanceId, String deviceId, String deviceSecret, String name) {}
+
+ /**
+ * Creates a new linked instance for {@code teamId}, returning the one-time plaintext secret.
+ */
+ @Transactional
+ public RegisteredInstance register(Long teamId, Long createdByUserId, String name) {
+ String deviceId = UUID.randomUUID().toString();
+ String deviceSecret = randomSecret();
+
+ LinkedInstance instance = new LinkedInstance();
+ instance.setTeamId(teamId);
+ instance.setCreatedByUserId(createdByUserId);
+ instance.setDeviceId(deviceId);
+ instance.setDeviceSecretHash(sha256Hex(deviceSecret));
+ instance.setName(name);
+ repo.save(instance);
+
+ log.info(
+ "Account-link: registered instance {} (device {}) for team {}",
+ instance.getInstanceId(),
+ deviceId,
+ teamId);
+ return new RegisteredInstance(instance.getInstanceId(), deviceId, deviceSecret, name);
+ }
+
+ /**
+ * All instances for a team, newest first (includes revoked, for the "Linked instances" list).
+ */
+ @Transactional(readOnly = true)
+ public List list(Long teamId) {
+ return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
+ }
+
+ /**
+ * Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
+ * a different team (so a caller can never revoke another team's instance). Idempotent.
+ */
+ @Transactional
+ public boolean revoke(Long teamId, Long instanceId) {
+ Optional found = repo.findById(instanceId);
+ if (found.isEmpty() || !found.get().getTeamId().equals(teamId)) {
+ return false;
+ }
+ LinkedInstance instance = found.get();
+ if (instance.getRevokedAt() == null) {
+ instance.setRevokedAt(LocalDateTime.now());
+ repo.save(instance);
+ log.info("Account-link: revoked instance {} for team {}", instanceId, teamId);
+ }
+ return true;
+ }
+
+ private String randomSecret() {
+ byte[] buf = new byte[SECRET_BYTES];
+ random.nextBytes(buf);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
+ }
+
+ /** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
+ static String sha256Hex(String value) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(md.digest(value.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java
new file mode 100644
index 0000000000..a2fd13a095
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java
@@ -0,0 +1,108 @@
+package stirling.software.saas.accountlink;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
+ *
+ *
Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
+ * LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
+ * stored hash. On a match it sets a {@link LinkedInstanceAuthenticationToken} (team-scoped, {@code
+ * ROLE_LINKED_INSTANCE}); otherwise it does nothing and lets the chain continue (→ 401 on a
+ * protected endpoint).
+ *
+ *
Read-only and path-scoped to {@code /api/v1/instance/**}: the device principal is never
+ * established for user-facing endpoints, so a leaked secret can only reach the instance surface.
+ * Gated behind {@code stirling.billing.account-link.enabled}; absent when the flag is off, so
+ * {@code SupabaseSecurityConfig} never wires it in.
+ */
+@Slf4j
+@Component
+@Profile("saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class DeviceCredentialAuthenticationFilter extends OncePerRequestFilter {
+
+ static final String HEADER_DEVICE_ID = "X-Device-Id";
+ static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
+ static final String INSTANCE_PATH_PREFIX = "/api/v1/instance/";
+
+ private final LinkedInstanceRepository repo;
+
+ public DeviceCredentialAuthenticationFilter(LinkedInstanceRepository repo) {
+ this.repo = repo;
+ }
+
+ /** Only the instance surface uses the device credential; everything else skips this filter. */
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ return !request.getRequestURI().startsWith(INSTANCE_PATH_PREFIX);
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+ String deviceId = request.getHeader(HEADER_DEVICE_ID);
+ String secret = request.getHeader(HEADER_DEVICE_SECRET);
+
+ if (deviceId != null
+ && secret != null
+ && SecurityContextHolder.getContext().getAuthentication() == null) {
+ repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
+ .ifPresent(
+ instance -> {
+ if (constantTimeEquals(
+ AccountLinkService.sha256Hex(secret),
+ instance.getDeviceSecretHash())) {
+ SecurityContextHolder.getContext()
+ .setAuthentication(
+ new LinkedInstanceAuthenticationToken(
+ instance.getInstanceId(),
+ instance.getTeamId()));
+ // Stamp liveness, best-effort. Auth is already set above; a
+ // transient write failure must NOT 500 an otherwise-valid
+ // request, so swallow it. Targeted single-column UPDATE (not a
+ // full save) so a concurrent revoke between the read above and
+ // this write can't be clobbered back to active.
+ try {
+ repo.touchLastSeen(
+ instance.getInstanceId(), LocalDateTime.now());
+ } catch (RuntimeException e) {
+ log.debug(
+ "last_seen_at update failed for device {}: {}",
+ deviceId,
+ e.getMessage());
+ }
+ } else {
+ log.debug("Device credential mismatch for device {}", deviceId);
+ }
+ });
+ }
+
+ chain.doFilter(request, response);
+ }
+
+ private static boolean constantTimeEquals(String a, String b) {
+ if (a == null || b == null) {
+ return false;
+ }
+ return MessageDigest.isEqual(
+ a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java
new file mode 100644
index 0000000000..7392eb928a
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java
@@ -0,0 +1,131 @@
+package stirling.software.saas.accountlink;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Hidden;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.saas.payg.billing.TeamBillingContext;
+import stirling.software.saas.payg.billing.TeamBillingService;
+import stirling.software.saas.payg.entitlement.EntitlementService;
+import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
+import stirling.software.saas.payg.model.EntitlementState;
+
+/**
+ * Instance-facing surface (combined-billing "Mode A"), authenticated by the device
+ * credential — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
+ * credential is scoped here and nowhere else.
+ *
+ *
{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
+ * device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
+ * /entitlement} is the read the local gate consumes — the same team-scoped snapshot the FE wallet
+ * sees, trimmed to the fields the gate needs (subscription, free pool, period spend/cap, state),
+ * and built on the same device-credential auth.
+ *
+ *
Gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → 404.
+ */
+@Slf4j
+@Hidden
+@RestController
+@RequestMapping("/api/v1/instance")
+@Profile("saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class InstanceController {
+
+ private final EntitlementService entitlementService;
+ private final TeamBillingService billingService;
+ private final AccountLinkService accountLinkService;
+
+ public InstanceController(
+ EntitlementService entitlementService,
+ TeamBillingService billingService,
+ AccountLinkService accountLinkService) {
+ this.entitlementService = entitlementService;
+ this.billingService = billingService;
+ this.accountLinkService = accountLinkService;
+ }
+
+ public record WhoAmIResponse(Long instanceId, Long teamId) {}
+
+ /**
+ * Minimal entitlement view the local gate enforces against. {@code periodCapUnits} null =
+ * uncapped. {@code state} is the coarse OK / OVER_LIMIT vocabulary the instance gate parses
+ * (see {@link #coarseState}), not the SaaS feature-state enum.
+ */
+ public record EntitlementResponse(
+ boolean subscribed,
+ long freeRemainingUnits,
+ long periodSpendUnits,
+ Long periodCapUnits,
+ String state) {}
+
+ @GetMapping("/whoami")
+ @PreAuthorize("hasRole('LINKED_INSTANCE')")
+ public ResponseEntity whoami(Authentication auth) {
+ if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
+ // Belt-and-braces: hasRole already guarantees this, but never leak a non-instance
+ // principal.
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+ return ResponseEntity.ok(new WhoAmIResponse(token.getInstanceId(), token.getTeamId()));
+ }
+
+ /**
+ * Revokes this instance's own credential — a credential can mark itself revoked the same way a
+ * session logs itself out. Called by the proprietary backend on local unlink so the SaaS row
+ * gets {@code revoked_at} set; idempotent (already-revoked → still 204).
+ */
+ @PostMapping("/revoke-self")
+ @PreAuthorize("hasRole('LINKED_INSTANCE')")
+ public ResponseEntity revokeSelf(Authentication auth) {
+ if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+ accountLinkService.revoke(token.getTeamId(), token.getInstanceId());
+ return ResponseEntity.noContent().build();
+ }
+
+ @GetMapping("/entitlement")
+ @PreAuthorize("hasRole('LINKED_INSTANCE')")
+ @Transactional(readOnly = true)
+ public ResponseEntity entitlement(Authentication auth) {
+ if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+ Long teamId = token.getTeamId();
+
+ // Same composition the FE wallet uses: billing facts (subscription, free pool) from
+ // TeamBillingService, period spend/cap + state from the entitlement snapshot.
+ TeamBillingContext billing = billingService.forTeam(teamId);
+ EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
+
+ return ResponseEntity.ok(
+ new EntitlementResponse(
+ billing.subscribed(),
+ billing.freeRemainingUnits(),
+ snap.periodSpendUnits(),
+ snap.periodCapUnits(),
+ coarseState(snap.state())));
+ }
+
+ /**
+ * Collapses the SaaS feature-state machine into the OK / OVER_LIMIT vocabulary the instance
+ * gate parses. DEGRADED means automation + AI are gated off — which, for a gate that governs
+ * only billable work (manual tools are free-pathed before it), is exactly OVER_LIMIT; FULL and
+ * WARNED are OK.
+ */
+ private static String coarseState(EntitlementState state) {
+ return state == EntitlementState.DEGRADED ? "OVER_LIMIT" : "OK";
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java
new file mode 100644
index 0000000000..ec92c97758
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java
@@ -0,0 +1,77 @@
+package stirling.software.saas.accountlink;
+
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.CreationTimestamp;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
+ * linked_instance}, V22).
+ *
+ *
Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
+ * short-lived Supabase JWT. Registration mints a {@code device_id} (public) plus a {@code
+ * device_secret} (high-entropy, returned once and stored only on the instance — we keep an unsalted
+ * SHA-256 hash, the same posture as API keys). The instance authenticates its unattended
+ * entitlement reads with that device credential, so no long-lived user JWT lives on the server
+ * side.
+ *
+ *
{@code revoked_at IS NULL} means active; revoking sets it and the credential stops
+ * authenticating. The whole surface is gated behind {@code stirling.billing.account-link.enabled}.
+ */
+@Entity
+@Table(name = "linked_instance")
+@Getter
+@Setter
+@NoArgsConstructor
+public class LinkedInstance {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "instance_id")
+ private Long instanceId;
+
+ @Column(name = "team_id", nullable = false)
+ private Long teamId;
+
+ /**
+ * Admin who registered the instance; informational (no FK, so a user delete never offlines it).
+ */
+ @Column(name = "created_by_user_id")
+ private Long createdByUserId;
+
+ /** Public, non-secret identifier the instance presents on every request. */
+ @Column(name = "device_id", nullable = false, unique = true, length = 64)
+ private String deviceId;
+
+ /** SHA-256 hex of the device secret; the secret itself is never stored. */
+ @Column(name = "device_secret_hash", nullable = false, length = 64)
+ private String deviceSecretHash;
+
+ /** Operator-set display label (hostname etc.) for the "Linked instances" list. */
+ @Column(name = "name", length = 255)
+ private String name;
+
+ /** Insert time; Hibernate populates this on persist (DB DEFAULT is belt-and-braces). */
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ /** Stamped when the device credential last authenticated; powers staleness display. */
+ @Column(name = "last_seen_at")
+ private LocalDateTime lastSeenAt;
+
+ /** NULL = active. Set on unlink/revoke; a revoked credential fails authentication. */
+ @Column(name = "revoked_at")
+ private LocalDateTime revokedAt;
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java
new file mode 100644
index 0000000000..af883bd66a
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java
@@ -0,0 +1,45 @@
+package stirling.software.saas.accountlink;
+
+import java.util.List;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+/**
+ * Authentication for a linked self-hosted instance (combined-billing "Mode A").
+ *
+ *
Deliberately not a user: the principal is the instance ({@code instanceId}) bound to
+ * a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
+ * User} and creates no user row — a device credential can never act as a person, only as its team's
+ * instance, and only on the instance-facing endpoints.
+ */
+public class LinkedInstanceAuthenticationToken extends AbstractAuthenticationToken {
+
+ private final Long instanceId;
+ private final Long teamId;
+
+ public LinkedInstanceAuthenticationToken(Long instanceId, Long teamId) {
+ super(List.of(new SimpleGrantedAuthority("ROLE_LINKED_INSTANCE")));
+ this.instanceId = instanceId;
+ this.teamId = teamId;
+ setAuthenticated(true);
+ }
+
+ @Override
+ public Object getCredentials() {
+ return null; // the secret is never retained on the authentication
+ }
+
+ @Override
+ public Object getPrincipal() {
+ return instanceId;
+ }
+
+ public Long getInstanceId() {
+ return instanceId;
+ }
+
+ public Long getTeamId() {
+ return teamId;
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java
new file mode 100644
index 0000000000..dae6a2a69e
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceRepository.java
@@ -0,0 +1,43 @@
+package stirling.software.saas.accountlink;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Data access for {@link LinkedInstance}. Plain Spring Data JPA against {@code stirling_pdf} —
+ * native schema access, no RPC, consistent with the rest of the SaaS backend.
+ */
+public interface LinkedInstanceRepository extends JpaRepository {
+
+ /**
+ * Active-credential lookup for the device-credential auth filter (revoked rows never match).
+ */
+ Optional findByDeviceIdAndRevokedAtIsNull(String deviceId);
+
+ /** Backs the portal "Linked instances" list (includes revoked, newest first). */
+ List findByTeamIdOrderByCreatedAtDesc(Long teamId);
+
+ /** Active (non-revoked) linked instances on a team — the orphan guard's count. */
+ long countByTeamIdAndRevokedAtIsNull(Long teamId);
+
+ /**
+ * Stamps liveness on a single instance. A targeted single-column UPDATE rather than a
+ * full-entity {@code save}: the auth filter loads the instance outside a transaction, so a full
+ * save would write back the stale (in-memory {@code null}) {@code revoked_at} and could
+ * silently un-revoke a credential that was revoked between the read and the write. The {@code
+ * revoked_at IS NULL} guard makes this a no-op once revoked.
+ */
+ @Modifying
+ @Transactional
+ @Query(
+ "UPDATE LinkedInstance li SET li.lastSeenAt = :now "
+ + "WHERE li.instanceId = :instanceId AND li.revokedAt IS NULL")
+ int touchLastSeen(@Param("instanceId") Long instanceId, @Param("now") LocalDateTime now);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
index 3c6b0d14df..5e90df6bf5 100644
--- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
+++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
@@ -14,12 +14,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Profile("saas")
@EnableJpaRepositories(
basePackages = {
+ "stirling.software.saas.accountlink",
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository"
})
@EntityScan({
+ "stirling.software.saas.accountlink",
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java
new file mode 100644
index 0000000000..6b8bb2c883
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java
@@ -0,0 +1,150 @@
+package stirling.software.saas.payg.api;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Hidden;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.payg.policy.PaygTeamExtensions;
+import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
+import stirling.software.saas.payg.stripe.StripeInvoiceDao;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.util.AuthenticationUtils;
+
+/**
+ * Read-only Stripe-invoices surface for the linked org's billing page.
+ *
+ *
{@code GET /api/v1/payg/invoices?limit=N} returns the team's most recent Stripe invoices,
+ * sourced from the {@code stripe.invoices} table the Sync Engine maintains. The caller's team is
+ * resolved from the authenticated principal (same pattern as {@link PaygWalletController}); we
+ * never trust a team id from the request.
+ *
+ *
Defensive: when the team has no {@code stripe_customer_id} (not subscribed, or pre-checkout)
+ * or the {@code stripe} schema isn't synced (H2 tests, sync engine off), we return {@code 200} with
+ * an empty list rather than 500 — the UI renders "no invoices yet". This keeps the page working
+ * through every link/subscription state.
+ *
+ *
{@code hostedInvoiceUrl} + {@code invoicePdf} are Stripe-hosted links the portal can deep-link
+ * from. We don't proxy the PDF ourselves; Stripe handles auth + caching.
+ */
+@Slf4j
+@Hidden
+@RestController
+@RequestMapping("/api/v1/payg")
+@Profile("saas")
+public class PaygInvoicesController {
+
+ private static final int DEFAULT_LIMIT = 20;
+ private static final int MAX_LIMIT = 100;
+
+ private final StripeInvoiceDao invoiceDao;
+ private final PaygTeamExtensionsRepository extRepo;
+ private final TeamMembershipRepository memberRepo;
+ private final UserRepository userRepository;
+
+ public PaygInvoicesController(
+ StripeInvoiceDao invoiceDao,
+ PaygTeamExtensionsRepository extRepo,
+ TeamMembershipRepository memberRepo,
+ UserRepository userRepository) {
+ this.invoiceDao = Objects.requireNonNull(invoiceDao, "invoiceDao");
+ this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
+ this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
+ this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
+ }
+
+ /** The shape the portal renders. Trimmed; never echoes raw Stripe object fields verbatim. */
+ public record InvoiceResponse(
+ String id,
+ String number,
+ String status,
+ Long totalMinor,
+ String currency,
+ String createdAt,
+ String periodStart,
+ String periodEnd,
+ String hostedInvoiceUrl,
+ String invoicePdf,
+ String description,
+ Long pdfsProcessed) {}
+
+ @GetMapping("/invoices")
+ @PreAuthorize("isAuthenticated()")
+ @Transactional(readOnly = true)
+ public ResponseEntity> list(
+ @RequestParam(name = "limit", required = false) Integer limit, Authentication auth) {
+
+ User user;
+ try {
+ user = AuthenticationUtils.getCurrentUser(auth, userRepository);
+ } catch (SecurityException e) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+
+ // Resolve the caller's team from their primary membership — same pattern as
+ // PaygWalletController. The team id NEVER comes from the request.
+ List rows = memberRepo.findPrimaryMembership(user.getId());
+ if (rows.isEmpty()) {
+ return ResponseEntity.ok(List.of());
+ }
+ Long teamId = rows.get(0).getTeam().getId();
+
+ // No PAYG extension row OR no Stripe customer id → team has never subscribed → no
+ // invoices. Empty list, not 404 — the UI distinguishes "no invoices yet" from a
+ // genuine error and we don't want to error a happy free team.
+ Optional ext = extRepo.findById(teamId);
+ if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
+ return ResponseEntity.ok(List.of());
+ }
+
+ int safeLimit = clampLimit(limit);
+ List body =
+ invoiceDao.findRecentByCustomer(ext.get().getStripeCustomerId(), safeLimit).stream()
+ .map(PaygInvoicesController::toResponse)
+ .toList();
+ return ResponseEntity.ok(body);
+ }
+
+ private static int clampLimit(Integer requested) {
+ if (requested == null) return DEFAULT_LIMIT;
+ return Math.max(1, Math.min(requested, MAX_LIMIT));
+ }
+
+ private static InvoiceResponse toResponse(StripeInvoiceDao.InvoiceRow r) {
+ return new InvoiceResponse(
+ r.id(),
+ r.number(),
+ r.status(),
+ r.totalMinor(),
+ r.currency(),
+ iso(r.createdAt()),
+ iso(r.periodStart()),
+ iso(r.periodEnd()),
+ r.hostedInvoiceUrl(),
+ r.invoicePdf(),
+ r.description(),
+ r.pdfsProcessed());
+ }
+
+ private static String iso(LocalDateTime ldt) {
+ return ldt == null ? null : ldt.toString();
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java
new file mode 100644
index 0000000000..724d6715d3
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java
@@ -0,0 +1,108 @@
+package stirling.software.saas.payg.api;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Hidden;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.payg.policy.PaygTeamExtensions;
+import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
+import stirling.software.saas.payg.stripe.StripePaymentMethodDao;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.util.AuthenticationUtils;
+
+/**
+ * Read-only default-payment-method surface for the subscribed billing page.
+ *
+ *
{@code GET /api/v1/payg/payment-method} returns the team's default card (brand / last4 /
+ * expiry), sourced from {@code stripe.payment_methods} (Sync Engine mirror). The caller's team is
+ * resolved from the authenticated principal — never trusted from the request — exactly as {@link
+ * PaygInvoicesController} does.
+ *
+ *
Defensive: no team, no {@code stripe_customer_id} (free / pre-checkout), or the card simply
+ * not in the mirror all degrade to {@code 200 present=false} rather than an error. Card edits never
+ * happen here; the portal deep-links to Stripe's hosted customer portal for that.
+ */
+@Slf4j
+@Hidden
+@RestController
+@RequestMapping("/api/v1/payg")
+@Profile("saas")
+public class PaygPaymentMethodController {
+
+ /** Trimmed default-card shape. {@code present=false} carries no card fields. */
+ public record PaymentMethodResponse(
+ boolean present, String brand, String last4, Integer expMonth, Integer expYear) {
+ static PaymentMethodResponse absent() {
+ return new PaymentMethodResponse(false, null, null, null, null);
+ }
+ }
+
+ private final StripePaymentMethodDao paymentMethodDao;
+ private final PaygTeamExtensionsRepository extRepo;
+ private final TeamMembershipRepository memberRepo;
+ private final UserRepository userRepository;
+
+ public PaygPaymentMethodController(
+ StripePaymentMethodDao paymentMethodDao,
+ PaygTeamExtensionsRepository extRepo,
+ TeamMembershipRepository memberRepo,
+ UserRepository userRepository) {
+ this.paymentMethodDao = Objects.requireNonNull(paymentMethodDao, "paymentMethodDao");
+ this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
+ this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
+ this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
+ }
+
+ @GetMapping("/payment-method")
+ @PreAuthorize("isAuthenticated()")
+ @Transactional(readOnly = true)
+ public ResponseEntity get(Authentication auth) {
+ User user;
+ try {
+ user = AuthenticationUtils.getCurrentUser(auth, userRepository);
+ } catch (SecurityException e) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+
+ List rows = memberRepo.findPrimaryMembership(user.getId());
+ if (rows.isEmpty()) {
+ return ResponseEntity.ok(PaymentMethodResponse.absent());
+ }
+ Long teamId = rows.get(0).getTeam().getId();
+
+ Optional ext = extRepo.findById(teamId);
+ if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
+ return ResponseEntity.ok(PaymentMethodResponse.absent());
+ }
+
+ return ResponseEntity.ok(
+ paymentMethodDao
+ .findDefaultCard(ext.get().getStripeCustomerId())
+ .map(
+ c ->
+ new PaymentMethodResponse(
+ true,
+ c.brand(),
+ c.last4(),
+ c.expMonth(),
+ c.expYear()))
+ .orElseGet(PaymentMethodResponse::absent));
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java
index d47b6e7365..29148a5ddb 100644
--- a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java
+++ b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java
@@ -15,7 +15,9 @@ import stirling.software.saas.payg.model.FeatureSet;
*
{@code capUnits <= 0} (an explicit $0 cap) → {@code DEGRADED}: metered work blocked, only
+ * the free grant + manual tools run.
*
{@code spend / cap < warnPct} → {@code FULL}.
*
MINIMAL semantics: under DEGRADED+MINIMAL manual server-side tools (gated by {@link
* FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link
@@ -49,9 +51,19 @@ public final class CapEvaluator {
int degradeAtPct,
FeatureSet degradedFeatureSet) {
- if (capUnits == null || capUnits <= 0) {
+ if (capUnits == null) {
+ // No cap configured → uncapped, full feature set.
return full();
}
+ if (capUnits <= 0) {
+ // An explicit cap that buys zero paid documents (a $0 cap, or one set
+ // below the per-document rate): metered work is blocked outright —
+ // only the free grant and manual tools run. DEGRADED, same as hitting
+ // a positive cap.
+ FeatureSet effective =
+ degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL;
+ return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective));
+ }
if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) {
// Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise
// degradation. The admin endpoints that set the policy should validate; this
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java
new file mode 100644
index 0000000000..8e6b59f9ce
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeInvoiceDao.java
@@ -0,0 +1,215 @@
+package stirling.software.saas.payg.stripe;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.dao.DataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Read-only accessor for {@code stripe.invoices} (synced into Postgres by the Stripe Sync Engine).
+ *
+ *
Same defensive posture as {@link StripeSubscriptionDao}: when the {@code stripe} schema is
+ * absent (H2 unit tests, sync engine not yet provisioned, or invoices not in the Sync Engine's
+ * target list), the lookup degrades to an empty list with a WARN — the caller renders "no invoices
+ * yet" rather than 500ing the page.
+ */
+@Slf4j
+@Repository
+@Profile("saas")
+public class StripeInvoiceDao {
+
+ /**
+ * One invoice row as the portal needs it. Money is in minor units of {@code currency} (e.g.
+ * cents for USD). {@code hostedInvoiceUrl} and {@code invoicePdf} are Stripe-hosted links that
+ * are stable for the lifetime of the invoice; safe to use as deep links from the UI.
+ *
+ *
{@code description} is the product name from the subscription chain — the portal renders
+ * this as the row label (matching Stripe's customer-portal row layout). Falls back to the
+ * invoice's own {@code description} field, then to null when neither is set.
+ */
+ public record InvoiceRow(
+ String id,
+ String number,
+ String status,
+ Long totalMinor,
+ String currency,
+ LocalDateTime createdAt,
+ LocalDateTime periodStart,
+ LocalDateTime periodEnd,
+ String hostedInvoiceUrl,
+ String invoicePdf,
+ String description,
+ /** Billed units (PDFs) on this invoice — summed line-item quantity; null if unknown. */
+ Long pdfsProcessed) {}
+
+ // Drafts are excluded: Stripe's API returns null for both
+ // {@code hosted_invoice_url} and {@code invoice_pdf} on unfinalized
+ // invoices, and Stripe's own customer portal hides drafts too — there's no
+ // user-facing artefact to surface yet. The next finalize / webhook flips
+ // the status and the invoice shows up automatically.
+ //
+ // The LATERAL join walks the same subscription → subscription_items → prices
+ // → products chain {@link StripeSubscriptionDao} uses to get the per-doc
+ // rate; here we use it to get the product NAME (e.g. "Stirling Processor
+ // Plan") so the portal can render Stripe's row label rather than the
+ // monospace invoice id. Falls back to {@code i.description}, then null.
+ private static final String QUERY =
+ "SELECT i.id, i.number, i.status::text AS status,"
+ + " i.total, i.currency,"
+ + " i.created, i.period_start, i.period_end,"
+ + " i.hosted_invoice_url, i.invoice_pdf,"
+ + " COALESCE(prod.name, i.description) AS description"
+ + " FROM stripe.invoices i"
+ + " LEFT JOIN LATERAL ("
+ + " SELECT si.price FROM stripe.subscription_items si"
+ + " WHERE si.subscription = i.subscription"
+ + " AND COALESCE(si.deleted, false) = false"
+ + " ORDER BY si.created DESC NULLS LAST LIMIT 1"
+ + " ) item ON true"
+ + " LEFT JOIN stripe.prices p ON p.id = item.price"
+ + " LEFT JOIN stripe.products prod ON prod.id = p.product"
+ + " WHERE i.customer = ?"
+ + " AND i.status::text <> 'draft'"
+ + " ORDER BY i.created DESC NULLS LAST"
+ + " LIMIT ?";
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public StripeInvoiceDao(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
+ }
+
+ /**
+ * The most recent {@code limit} invoices for {@code stripeCustomerId}, newest first. Empty list
+ * on missing schema / no rows / connectivity blip — the controller surfaces this as 200 with an
+ * empty body rather than 500.
+ */
+ public List findRecentByCustomer(String stripeCustomerId, int limit) {
+ if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
+ return List.of();
+ }
+ int safeLimit = Math.max(1, Math.min(limit, 100));
+ List rows;
+ try {
+ rows =
+ jdbcTemplate.query(
+ QUERY,
+ (rs, i) ->
+ new InvoiceRow(
+ rs.getString("id"),
+ rs.getString("number"),
+ rs.getString("status"),
+ nullableLong(rs, "total"),
+ rs.getString("currency"),
+ toLocal(rs.getLong("created"), rs.wasNull()),
+ toLocal(rs.getLong("period_start"), rs.wasNull()),
+ toLocal(rs.getLong("period_end"), rs.wasNull()),
+ rs.getString("hosted_invoice_url"),
+ rs.getString("invoice_pdf"),
+ rs.getString("description"),
+ null),
+ stripeCustomerId,
+ safeLimit);
+ } catch (DataAccessException e) {
+ log.warn(
+ "stripe.invoices lookup failed for customer {}: {}",
+ stripeCustomerId,
+ e.getMessage());
+ return List.of();
+ }
+ if (rows.isEmpty()) {
+ return rows;
+ }
+ Map billed = sumBilledUnits(rows.stream().map(InvoiceRow::id).toList());
+ if (billed.isEmpty()) {
+ return rows;
+ }
+ return rows.stream()
+ .map(
+ r ->
+ new InvoiceRow(
+ r.id(),
+ r.number(),
+ r.status(),
+ r.totalMinor(),
+ r.currency(),
+ r.createdAt(),
+ r.periodStart(),
+ r.periodEnd(),
+ r.hostedInvoiceUrl(),
+ r.invoicePdf(),
+ r.description(),
+ billed.get(r.id())))
+ .toList();
+ }
+
+ /**
+ * Sums billed quantity (PDFs) per invoice from the {@code stripe.invoices.lines} JSONB the Sync
+ * Engine mirrors — line items live in {@code lines->'data'}, NOT a separate {@code
+ * invoice_line_items} table (the sync engine never creates one).
+ *
+ *
Only the metered usage line counts: a Processor invoice can also carry flat
+ * subscription-fee, proration and tax lines, each with its own {@code quantity}, so summing
+ * every line would inflate the headline PDF count (usage 500 + a fee line of 1 → "501"). We
+ * filter on {@code price.recurring.usage_type = 'metered'}. When no metered line is present the
+ * subquery is {@code NULL} and the invoice is omitted from the map, so {@code
+ * InvoiceRow.pdfsProcessed} stays {@code null} and the column renders "—" rather than "0".
+ *
+ *
Run SEPARATELY from the invoice query and defensively wrapped, so a missing/changed schema
+ * degrades to an empty map (every row renders "—") instead of failing the whole invoice list.
+ */
+ private Map sumBilledUnits(List invoiceIds) {
+ if (invoiceIds.isEmpty()) {
+ return Map.of();
+ }
+ String placeholders = invoiceIds.stream().map(id -> "?").collect(Collectors.joining(","));
+ String sql =
+ "SELECT i.id AS invoice_id,"
+ + " (SELECT SUM((l->>'quantity')::int)"
+ + " FROM jsonb_array_elements(COALESCE(i.lines->'data', '[]'::jsonb)) AS l"
+ + " WHERE l->'price'->'recurring'->>'usage_type' = 'metered') AS qty"
+ + " FROM stripe.invoices i"
+ + " WHERE i.id IN ("
+ + placeholders
+ + ")";
+ try {
+ Map map = new HashMap<>();
+ jdbcTemplate.query(
+ sql,
+ (java.sql.ResultSet rs) -> {
+ long qty = rs.getLong("qty");
+ if (!rs.wasNull()) {
+ // null (no metered line) → leave the key absent → renders "—".
+ map.put(rs.getString("invoice_id"), qty);
+ }
+ },
+ invoiceIds.toArray());
+ return map;
+ } catch (DataAccessException e) {
+ log.warn("stripe.invoices line-quantity sum failed: {}", e.getMessage());
+ return Map.of();
+ }
+ }
+
+ private static Long nullableLong(java.sql.ResultSet rs, String column)
+ throws java.sql.SQLException {
+ long v = rs.getLong(column);
+ return rs.wasNull() ? null : v;
+ }
+
+ private static LocalDateTime toLocal(long epochSeconds, boolean wasNull) {
+ if (wasNull) return null;
+ return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault());
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java
new file mode 100644
index 0000000000..756e7dceb8
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripePaymentMethodDao.java
@@ -0,0 +1,91 @@
+package stirling.software.saas.payg.stripe;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.dao.DataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Read-only accessor for a team's default card off the Stripe Sync Engine schema ({@code
+ * stripe.payment_methods}). Prefers the customer's {@code invoice_settings.default_payment_method};
+ * falls back to their most recently created card. Card details (brand / last4 / expiry) live in the
+ * {@code card} JSONB column the sync engine mirrors.
+ *
+ *
Same defensive posture as {@link StripeInvoiceDao}/{@link StripeSubscriptionDao}: a missing
+ * schema or table — H2 unit tests, sync engine not provisioned, or {@code payment_methods} simply
+ * absent from the sync target list — degrades to {@link Optional#empty()} with a WARN, so the
+ * endpoint reports "no card on file" rather than 500ing the page. Editing always happens in
+ * Stripe's hosted portal; this never writes.
+ */
+@Slf4j
+@Repository
+@Profile("saas")
+public class StripePaymentMethodDao {
+
+ /** Card brand (e.g. "visa"), last 4 digits, and numeric expiry; any field may be null. */
+ public record CardSummary(String brand, String last4, Integer expMonth, Integer expYear) {}
+
+ private static final String QUERY =
+ "SELECT pm.card->>'brand' AS brand, pm.card->>'last4' AS last4,"
+ + " pm.card->>'exp_month' AS exp_month, pm.card->>'exp_year' AS exp_year"
+ + " FROM stripe.payment_methods pm"
+ + " WHERE pm.customer = ? AND pm.type = 'card'"
+ + " ORDER BY (pm.id = ("
+ + " SELECT c.invoice_settings->>'default_payment_method'"
+ + " FROM stripe.customers c WHERE c.id = ?"
+ + " )) DESC NULLS LAST, pm.created DESC NULLS LAST"
+ + " LIMIT 1";
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public StripePaymentMethodDao(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
+ }
+
+ /** The customer's default card; empty on missing schema / no card / connectivity blip. */
+ public Optional findDefaultCard(String stripeCustomerId) {
+ if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
+ return Optional.empty();
+ }
+ try {
+ List rows =
+ jdbcTemplate.query(
+ QUERY,
+ (rs, i) ->
+ new CardSummary(
+ rs.getString("brand"),
+ rs.getString("last4"),
+ parseIntOrNull(rs, "exp_month"),
+ parseIntOrNull(rs, "exp_year")),
+ stripeCustomerId,
+ stripeCustomerId);
+ return rows.stream().filter(Objects::nonNull).findFirst();
+ } catch (DataAccessException e) {
+ log.warn(
+ "stripe.payment_methods lookup failed for customer {}: {}",
+ stripeCustomerId,
+ e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private static Integer parseIntOrNull(ResultSet rs, String column) throws SQLException {
+ String raw = rs.getString(column);
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ try {
+ return Integer.valueOf(raw.trim());
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java
index 967b725b5e..5fe27a11e8 100644
--- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java
+++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java
@@ -10,6 +10,7 @@ import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -49,6 +50,7 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
+import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.service.SupabaseUserService;
@@ -80,7 +82,10 @@ public class SupabaseSecurityConfig {
private long clockSkewSeconds;
@Bean
- SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder)
+ SecurityFilterChain saasSecurityFilterChain(
+ HttpSecurity http,
+ JwtDecoder jwtDecoder,
+ ObjectProvider deviceCredentialFilterProvider)
throws Exception {
// CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in
// Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no
@@ -135,6 +140,16 @@ public class SupabaseSecurityConfig {
.jwtAuthenticationConverter(
SupabaseSecurityConfig
::toAuthentication)));
+
+ // Device-credential auth for linked self-hosted instances (combined-billing Mode A).
+ // The filter bean exists only when stirling.billing.account-link.enabled=true; when off it
+ // is absent here, so the instance surface cannot authenticate at all until release.
+ DeviceCredentialAuthenticationFilter deviceFilter =
+ deviceCredentialFilterProvider.getIfAvailable();
+ if (deviceFilter != null) {
+ http.addFilterBefore(deviceFilter, BearerTokenAuthenticationFilter.class);
+ }
+
return http.build();
}
diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java
index e54aa00df2..6d3e42e806 100644
--- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java
+++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java
@@ -19,6 +19,7 @@ import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
+import stirling.software.saas.accountlink.LinkedInstanceRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamInvitation;
@@ -45,6 +46,7 @@ public class SaasTeamService {
private final UserRoleService userRoleService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
+ private final LinkedInstanceRepository linkedInstanceRepository;
private final stirling.software.proprietary.security.service.UserService userService;
public static final String DEFAULT_TEAM_NAME = "Default";
@@ -458,22 +460,42 @@ public class SaasTeamService {
* accept. The message points them at the right remedy — cancel the plan if the team is paid,
* otherwise transfer leadership first.
*
+ *
Linked self-hosted instances (combined-billing "Mode A") bind to a team via {@code
+ * linked_instance.team_id}, so they too orphan a team that is left memberless — a personal team
+ * that accept deletes, or a non-personal team left by its last leader. They're checked in that
+ * same orphaning branch (not for a non-leader leaving a team that lives on); the remedy is to
+ * revoke them.
+ *
* @param user the user attempting to accept an invitation
- * @throws IllegalStateException if accepting would orphan a team the user leads
+ * @throws IllegalStateException if accepting would orphan a team the user leads or its
+ * instances
*/
private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
Team team = membership.getTeam();
- if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) {
- // Personal teams are deleted on accept; non-leaders leaving never orphans a team.
+ boolean personal = saasTeamExtensionService.isPersonal(team);
+ if (!personal && !membership.isLeader()) {
+ // A non-leader leaving a shared team never orphans it.
continue;
}
- // Only reached for a non-personal team the user leads — at most one such team in the
- // one-team-per-user model — so this count runs ~once, not per membership.
- if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) {
+ if (!personal
+ && membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER)
+ > 1) {
// Another leader remains, so the team keeps an owner.
continue;
}
+ // Leaving here orphans the team: a personal team is deleted on accept; a non-personal
+ // team is being left by its last leader. Either way its linked self-hosted instances
+ // lose their billing team, so block until they're revoked.
+ if (linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(team.getId()) > 0) {
+ throw new IllegalStateException(
+ "Revoke linked self-hosted instances on this team before joining another"
+ + " team.");
+ }
+ if (personal) {
+ // Personal teams are disposable (deleted on accept) and never billed/shared.
+ continue;
+ }
if (hasActivePaidSubscription(team)) {
throw new IllegalStateException(
"Your team has an active plan and you are its last leader. Cancel the plan"
diff --git a/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql b/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql
new file mode 100644
index 0000000000..9c975f1ce7
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql
@@ -0,0 +1,44 @@
+-- Account-link instances. One row per self-hosted instance that has linked a SaaS account.
+--
+-- Part of the combined-billing "Mode A" (connected self-hosted) flow:
+-- 1. An admin signs into their SaaS account in the Stirling Portal via the Supabase JS SDK
+-- (a short-lived Supabase JWT, refreshed client-side — it never reaches the server long-term).
+-- 2. That JWT is used ONCE to call POST /api/v1/account-link/register, which mints a
+-- device_id + device_secret bound to the admin's team. The secret is returned once and
+-- stored only on the instance; we keep a SHA-256 hash here (the secret is high-entropy,
+-- so an unsalted hash is sufficient — same posture as API keys).
+-- 3. The instance authenticates all unattended metering / entitlement calls with that device
+-- credential. No long-lived user JWT lives on the server side.
+--
+-- Twin of supabase/migrations/20260619000000_account_link_instances.sql (Stirling-PDF-SaaS).
+-- Inert until release: the AccountLinkController + device-credential filter are gated behind
+-- stirling.billing.account-link.enabled (default off). The table itself is harmless additive.
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.linked_instance (
+ instance_id BIGSERIAL PRIMARY KEY,
+ team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
+ created_by_user_id BIGINT,
+ -- admin who registered the instance; informational only (no FK so a user delete never
+ -- cascades a working instance offline).
+ device_id VARCHAR(64) NOT NULL UNIQUE,
+ -- public, non-secret identifier the instance presents on every request.
+ device_secret_hash VARCHAR(64) NOT NULL,
+ -- SHA-256 hex of the device secret; the secret itself is never stored.
+ name VARCHAR(255),
+ -- operator-set display label (hostname etc.) for the "Linked instances" list.
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at TIMESTAMP,
+ -- stamped when the device credential last authenticated; powers staleness display.
+ revoked_at TIMESTAMP
+ -- NULL = active. Set on unlink/revoke; a revoked credential fails authentication.
+);
+
+CREATE INDEX IF NOT EXISTS idx_linked_instance_team
+ ON stirling_pdf.linked_instance (team_id);
+
+COMMENT ON TABLE stirling_pdf.linked_instance IS
+ 'One row per self-hosted instance linked to a SaaS account (combined-billing Mode A). '
+ 'device_id is the public identifier; device_secret_hash is the SHA-256 of the bearer '
+ 'secret (returned once at registration, stored only on the instance). The instance '
+ 'authenticates unattended metering / entitlement calls with this credential; revoked_at '
+ 'IS NULL means active.';
diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java
new file mode 100644
index 0000000000..0de790cac0
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java
@@ -0,0 +1,169 @@
+package stirling.software.saas.accountlink;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import stirling.software.common.model.enumeration.TeamRole;
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest;
+import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.util.AuthenticationUtils;
+
+/**
+ * Pure-Mockito unit tests for {@link AccountLinkController} — the leader-only auth ladder, and that
+ * the team is always derived from the caller's membership (never the request). Mirrors {@code
+ * PaygInvoicesControllerTest}'s static-mock of {@link AuthenticationUtils}.
+ */
+@ExtendWith(MockitoExtension.class)
+class AccountLinkControllerTest {
+
+ @Mock private AccountLinkService service;
+ @Mock private TeamMembershipRepository memberRepo;
+ @Mock private UserRepository userRepository;
+
+ private AccountLinkController controller;
+ private Authentication auth;
+
+ @BeforeEach
+ void setUp() {
+ controller = new AccountLinkController(service, memberRepo, userRepository);
+ auth =
+ new AnonymousAuthenticationToken(
+ "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ }
+
+ @Test
+ void register_unauthenticated_returns401() {
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenThrow(new SecurityException("not authenticated"));
+
+ ResponseEntity resp =
+ controller.register(new RegisterRequest("host"), auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+ verifyNoInteractions(service);
+ }
+ }
+
+ @Test
+ void register_noMembership_returns403() {
+ User user = mockUser(42L);
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
+
+ ResponseEntity resp = controller.register(null, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ verifyNoInteractions(service);
+ }
+ }
+
+ @Test
+ void register_nonLeader_returns403() {
+ User user = mockUser(42L);
+ TeamMembership member = membership(7L, TeamRole.MEMBER);
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member));
+
+ ResponseEntity resp = controller.register(null, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ verifyNoInteractions(service);
+ }
+ }
+
+ @Test
+ void register_leader_mintsCredentialForCallerTeam() {
+ User user = mockUser(42L);
+ TeamMembership leader = membership(7L, TeamRole.LEADER);
+ when(service.register(7L, 42L, "host"))
+ .thenReturn(
+ new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host"));
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
+
+ ResponseEntity resp =
+ controller.register(new RegisterRequest("host"), auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
+ RegisterResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ // Team comes from the caller's membership and is surfaced in the response.
+ assertThat(body.teamId()).isEqualTo(7L);
+ assertThat(body.instanceId()).isEqualTo(99L);
+ assertThat(body.deviceSecret()).isEqualTo("sec-x");
+ }
+ }
+
+ @Test
+ void revoke_leader_returns204WhenServiceRevokes() {
+ User user = mockUser(42L);
+ TeamMembership leader = membership(7L, TeamRole.LEADER);
+ when(service.revoke(7L, 11L)).thenReturn(true);
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
+
+ ResponseEntity resp = controller.revoke(11L, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
+ }
+ }
+
+ @Test
+ void revoke_leader_returns404WhenServiceReportsNotFound() {
+ User user = mockUser(42L);
+ TeamMembership leader = membership(7L, TeamRole.LEADER);
+ when(service.revoke(7L, 11L)).thenReturn(false);
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
+
+ ResponseEntity resp = controller.revoke(11L, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+ }
+
+ private static User mockUser(long id) {
+ User u = new User();
+ u.setId(id);
+ return u;
+ }
+
+ private static TeamMembership membership(long teamId, TeamRole role) {
+ Team team = new Team();
+ team.setId(teamId);
+ TeamMembership tm = new TeamMembership();
+ tm.setTeam(team);
+ tm.setRole(role);
+ return tm;
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java
new file mode 100644
index 0000000000..3d725b98d6
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkServiceTest.java
@@ -0,0 +1,102 @@
+package stirling.software.saas.accountlink;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import stirling.software.saas.accountlink.AccountLinkService.RegisteredInstance;
+
+/**
+ * Pure-Mockito unit tests for {@link AccountLinkService}: register returns the plaintext secret
+ * once but persists only its hash, and revoke is team-scoped + idempotent — a caller can never
+ * revoke another team's instance.
+ */
+@ExtendWith(MockitoExtension.class)
+class AccountLinkServiceTest {
+
+ @Mock private LinkedInstanceRepository repo;
+
+ private AccountLinkService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new AccountLinkService(repo);
+ }
+
+ @Test
+ void register_returnsPlaintextSecretOnce_persistsOnlyHash() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(LinkedInstance.class);
+
+ RegisteredInstance reg = service.register(42L, 7L, "host-a");
+
+ verify(repo).save(captor.capture());
+ LinkedInstance saved = captor.getValue();
+ assertThat(reg.deviceSecret()).isNotBlank();
+ assertThat(reg.deviceId()).isEqualTo(saved.getDeviceId());
+ assertThat(saved.getDeviceSecretHash())
+ .isEqualTo(AccountLinkService.sha256Hex(reg.deviceSecret()))
+ .isNotEqualTo(reg.deviceSecret());
+ assertThat(saved.getTeamId()).isEqualTo(42L);
+ assertThat(saved.getCreatedByUserId()).isEqualTo(7L);
+ assertThat(saved.getName()).isEqualTo("host-a");
+ }
+
+ @Test
+ void revoke_owningTeam_setsRevokedAtAndReturnsTrue() {
+ LinkedInstance inst = instance(11L, 42L, null);
+ when(repo.findById(11L)).thenReturn(Optional.of(inst));
+
+ assertThat(service.revoke(42L, 11L)).isTrue();
+ assertThat(inst.getRevokedAt()).isNotNull();
+ verify(repo).save(inst);
+ }
+
+ @Test
+ void revoke_alreadyRevoked_isIdempotentAndDoesNotResave() {
+ LocalDateTime revoked = LocalDateTime.now().minusDays(1);
+ LinkedInstance inst = instance(11L, 42L, revoked);
+ when(repo.findById(11L)).thenReturn(Optional.of(inst));
+
+ assertThat(service.revoke(42L, 11L)).isTrue();
+ assertThat(inst.getRevokedAt()).isEqualTo(revoked);
+ verify(repo, never()).save(any());
+ }
+
+ @Test
+ void revoke_otherTeamsInstance_returnsFalseAndDoesNotSave() {
+ LinkedInstance inst = instance(11L, 99L, null);
+ when(repo.findById(11L)).thenReturn(Optional.of(inst));
+
+ assertThat(service.revoke(42L, 11L)).isFalse();
+ assertThat(inst.getRevokedAt()).isNull();
+ verify(repo, never()).save(any());
+ }
+
+ @Test
+ void revoke_unknownInstance_returnsFalse() {
+ when(repo.findById(404L)).thenReturn(Optional.empty());
+
+ assertThat(service.revoke(42L, 404L)).isFalse();
+ verify(repo, never()).save(any());
+ }
+
+ private static LinkedInstance instance(Long id, Long teamId, LocalDateTime revokedAt) {
+ LinkedInstance i = new LinkedInstance();
+ i.setInstanceId(id);
+ i.setTeamId(teamId);
+ i.setRevokedAt(revokedAt);
+ return i;
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java
new file mode 100644
index 0000000000..5b42e41a49
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilterTest.java
@@ -0,0 +1,162 @@
+package stirling.software.saas.accountlink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import jakarta.servlet.ServletException;
+
+@ExtendWith(MockitoExtension.class)
+class DeviceCredentialAuthenticationFilterTest {
+
+ @Mock private LinkedInstanceRepository repo;
+
+ private DeviceCredentialAuthenticationFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ filter = new DeviceCredentialAuthenticationFilter(repo);
+ SecurityContextHolder.clearContext();
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ private static LinkedInstance instanceWithSecret(String secret) {
+ LinkedInstance i = new LinkedInstance();
+ i.setInstanceId(1L);
+ i.setTeamId(42L);
+ i.setDeviceId("dev-1");
+ i.setDeviceSecretHash(AccountLinkService.sha256Hex(secret));
+ return i;
+ }
+
+ private static MockHttpServletRequest instanceRequest(String deviceId, String secret) {
+ MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/instance/whoami");
+ if (deviceId != null) {
+ req.addHeader("X-Device-Id", deviceId);
+ }
+ if (secret != null) {
+ req.addHeader("X-Device-Secret", secret);
+ }
+ return req;
+ }
+
+ @Test
+ void validCredentialAuthenticatesAsInstanceBoundToTeam() throws ServletException, IOException {
+ when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1"))
+ .thenReturn(Optional.of(instanceWithSecret("s3cr3t")));
+
+ filter.doFilter(
+ instanceRequest("dev-1", "s3cr3t"),
+ new MockHttpServletResponse(),
+ new MockFilterChain());
+
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ assertInstanceOf(LinkedInstanceAuthenticationToken.class, auth);
+ LinkedInstanceAuthenticationToken token = (LinkedInstanceAuthenticationToken) auth;
+ assertEquals(42L, token.getTeamId());
+ assertEquals(1L, token.getInstanceId());
+ assertEquals(
+ "ROLE_LINKED_INSTANCE", token.getAuthorities().iterator().next().getAuthority());
+ }
+
+ @Test
+ void successfulAuthStampsLastSeen() throws ServletException, IOException {
+ LinkedInstance instance = instanceWithSecret("s3cr3t");
+ when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance));
+
+ filter.doFilter(
+ instanceRequest("dev-1", "s3cr3t"),
+ new MockHttpServletResponse(),
+ new MockFilterChain());
+
+ // Targeted single-column update (guarded by revoked_at IS NULL), not a full-entity save.
+ verify(repo).touchLastSeen(eq(1L), any(LocalDateTime.class));
+ verify(repo, never()).save(any());
+ }
+
+ @Test
+ void lastSeenWriteFailureDoesNotBreakAuth() throws ServletException, IOException {
+ LinkedInstance instance = instanceWithSecret("s3cr3t");
+ when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance));
+ doThrow(new RuntimeException("transient db"))
+ .when(repo)
+ .touchLastSeen(anyLong(), any(LocalDateTime.class));
+
+ // A liveness-write failure must NOT propagate — auth is already set, so the
+ // request stays authenticated rather than 500ing.
+ filter.doFilter(
+ instanceRequest("dev-1", "s3cr3t"),
+ new MockHttpServletResponse(),
+ new MockFilterChain());
+
+ assertInstanceOf(
+ LinkedInstanceAuthenticationToken.class,
+ SecurityContextHolder.getContext().getAuthentication());
+ }
+
+ @Test
+ void wrongSecretDoesNotAuthenticate() throws ServletException, IOException {
+ when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1"))
+ .thenReturn(Optional.of(instanceWithSecret("right-secret")));
+
+ filter.doFilter(
+ instanceRequest("dev-1", "wrong-secret"),
+ new MockHttpServletResponse(),
+ new MockFilterChain());
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ }
+
+ @Test
+ void unknownOrRevokedDeviceDoesNotAuthenticate() throws ServletException, IOException {
+ when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.empty());
+
+ filter.doFilter(
+ instanceRequest("dev-1", "whatever"),
+ new MockHttpServletResponse(),
+ new MockFilterChain());
+
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ }
+
+ @Test
+ void nonInstancePathIsSkippedEntirely() throws ServletException, IOException {
+ MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payg/wallet");
+ req.addHeader("X-Device-Id", "dev-1");
+ req.addHeader("X-Device-Secret", "s3cr3t");
+
+ filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain());
+
+ // Path-scoped: the device credential never even reaches the repo on a non-instance path.
+ assertNull(SecurityContextHolder.getContext().getAuthentication());
+ verifyNoInteractions(repo);
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java
new file mode 100644
index 0000000000..d221ac8603
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java
@@ -0,0 +1,190 @@
+package stirling.software.saas.accountlink;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import stirling.software.saas.accountlink.InstanceController.EntitlementResponse;
+import stirling.software.saas.payg.billing.TeamBillingContext;
+import stirling.software.saas.payg.billing.TeamBillingService;
+import stirling.software.saas.payg.entitlement.EntitlementService;
+import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
+import stirling.software.saas.payg.model.EntitlementState;
+import stirling.software.saas.payg.model.FeatureGate;
+import stirling.software.saas.payg.model.FeatureSet;
+
+/**
+ * Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read.
+ * The team is resolved from the {@link LinkedInstanceAuthenticationToken} principal, never a path
+ * or body, and the minimal DTO maps straight off the billing context + entitlement snapshot.
+ */
+@ExtendWith(MockitoExtension.class)
+class InstanceControllerTest {
+
+ @Mock private EntitlementService entitlementService;
+ @Mock private TeamBillingService billingService;
+ @Mock private AccountLinkService accountLinkService;
+
+ private InstanceController controller() {
+ return new InstanceController(entitlementService, billingService, accountLinkService);
+ }
+
+ @Test
+ void entitlement_resolvesTeamFromTokenAndMapsSnapshot() {
+ Authentication token = new LinkedInstanceAuthenticationToken(1L, 42L);
+ when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L));
+ when(entitlementService.getSnapshot(42L))
+ .thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L));
+
+ ResponseEntity resp = controller().entitlement(token);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ EntitlementResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.subscribed()).isTrue();
+ assertThat(body.freeRemainingUnits()).isEqualTo(120L);
+ assertThat(body.periodSpendUnits()).isEqualTo(90L);
+ assertThat(body.periodCapUnits()).isEqualTo(1250L);
+ // WARNED is still within budget for the gate's purposes → coarse OK.
+ assertThat(body.state()).isEqualTo("OK");
+ }
+
+ @Test
+ void entitlement_uncapped_returnsNullCapUnits() {
+ Authentication token = new LinkedInstanceAuthenticationToken(2L, 7L);
+ when(billingService.forTeam(7L)).thenReturn(freeBilling(500L));
+ when(entitlementService.getSnapshot(7L))
+ .thenReturn(snapshot(EntitlementState.FULL, 0L, null));
+
+ ResponseEntity resp = controller().entitlement(token);
+
+ EntitlementResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.subscribed()).isFalse();
+ assertThat(body.freeRemainingUnits()).isEqualTo(500L);
+ assertThat(body.periodCapUnits()).isNull();
+ assertThat(body.state()).isEqualTo("OK");
+ }
+
+ @Test
+ void entitlement_degradedMapsToOverLimit() {
+ // The instance gate parses OK / OVER_LIMIT, never the SaaS FULL/WARNED/DEGRADED enum.
+ // DEGRADED (automation + AI gated) must reach the wire as OVER_LIMIT.
+ Authentication token = new LinkedInstanceAuthenticationToken(3L, 8L);
+ when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L));
+ when(entitlementService.getSnapshot(8L))
+ .thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L));
+
+ EntitlementResponse body = controller().entitlement(token).getBody();
+
+ assertThat(body).isNotNull();
+ assertThat(body.state()).isEqualTo("OVER_LIMIT");
+ }
+
+ @Test
+ void entitlement_nonInstancePrincipalIsRejected() {
+ Authentication anon =
+ new AnonymousAuthenticationToken(
+ "k",
+ "anonymousUser",
+ List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
+
+ ResponseEntity resp = controller().entitlement(anon);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+ verifyNoInteractions(entitlementService, billingService);
+ }
+
+ @Test
+ void revokeSelf_callsServiceWithTokenIdentityAndReturns204() {
+ Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L);
+
+ ResponseEntity resp = controller().revokeSelf(token);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
+ verify(accountLinkService).revoke(22L, 11L);
+ }
+
+ @Test
+ void revokeSelf_rejectsNonInstancePrincipal() {
+ Authentication anon =
+ new AnonymousAuthenticationToken(
+ "k",
+ "anonymousUser",
+ List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
+
+ ResponseEntity resp = controller().revokeSelf(anon);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+ verifyNoInteractions(accountLinkService);
+ }
+
+ @Test
+ void whoami_returnsResolvedInstanceAndTeam() {
+ Authentication token = new LinkedInstanceAuthenticationToken(5L, 9L);
+
+ ResponseEntity resp = controller().whoami(token);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody().instanceId()).isEqualTo(5L);
+ assertThat(resp.getBody().teamId()).isEqualTo(9L);
+ }
+
+ private static TeamBillingContext freeBilling(long freeRemaining) {
+ LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
+ return new TeamBillingContext(
+ false,
+ null,
+ start,
+ start.plusMonths(1),
+ freeRemaining,
+ freeRemaining,
+ null,
+ null,
+ null,
+ null);
+ }
+
+ private static TeamBillingContext subscribedBilling(String subId, long freeRemaining) {
+ LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
+ return new TeamBillingContext(
+ true,
+ subId,
+ start,
+ start.plusMonths(1),
+ 500L,
+ freeRemaining,
+ BigDecimal.valueOf(2),
+ "usd",
+ 2500L,
+ 1250L);
+ }
+
+ private static EntitlementSnapshot snapshot(EntitlementState state, long spend, Long cap) {
+ LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
+ return new EntitlementSnapshot(
+ state,
+ FeatureSet.FULL,
+ List.of(FeatureGate.OFFSITE_PROCESSING),
+ spend,
+ cap,
+ start,
+ start.plusMonths(1),
+ false);
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java
new file mode 100644
index 0000000000..9266b02336
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java
@@ -0,0 +1,189 @@
+package stirling.software.saas.payg.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.payg.api.PaygInvoicesController.InvoiceResponse;
+import stirling.software.saas.payg.policy.PaygTeamExtensions;
+import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
+import stirling.software.saas.payg.stripe.StripeInvoiceDao;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.util.AuthenticationUtils;
+
+/**
+ * Pure-Mockito unit tests for {@link PaygInvoicesController}. Confirms team is resolved from the
+ * authenticated principal (never request), and the empty-list degrade paths (no team, no Stripe
+ * customer, no rows) all return 200 + [] rather than 4xx/5xx.
+ */
+@ExtendWith(MockitoExtension.class)
+class PaygInvoicesControllerTest {
+
+ @Mock private StripeInvoiceDao invoiceDao;
+ @Mock private PaygTeamExtensionsRepository extRepo;
+ @Mock private TeamMembershipRepository memberRepo;
+ @Mock private UserRepository userRepository;
+
+ private PaygInvoicesController controller;
+ private Authentication auth;
+
+ @BeforeEach
+ void setUp() {
+ controller = new PaygInvoicesController(invoiceDao, extRepo, memberRepo, userRepository);
+ auth =
+ new AnonymousAuthenticationToken(
+ "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
+ }
+
+ @Test
+ void list_unauthenticated_returns401() {
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenThrow(new SecurityException("not authenticated"));
+
+ ResponseEntity> resp = controller.list(null, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+ verifyNoInteractions(invoiceDao, extRepo, memberRepo);
+ }
+ }
+
+ @Test
+ void list_noTeam_returnsEmpty() {
+ User user = mockUser(42L);
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
+
+ ResponseEntity> resp = controller.list(null, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).isEmpty();
+ verifyNoInteractions(invoiceDao, extRepo);
+ }
+ }
+
+ @Test
+ void list_noStripeCustomer_returnsEmpty() {
+ User user = mockUser(42L);
+ TeamMembership tm = mockMembership(7L);
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
+ when(extRepo.findById(7L)).thenReturn(Optional.empty());
+
+ ResponseEntity> resp = controller.list(null, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).isEmpty();
+ verifyNoInteractions(invoiceDao);
+ }
+ }
+
+ @Test
+ void list_mapsRowsAndClampsLimit() {
+ User user = mockUser(42L);
+ TeamMembership tm = mockMembership(7L);
+ PaygTeamExtensions ext = new PaygTeamExtensions();
+ ext.setTeamId(7L);
+ ext.setStripeCustomerId("cus_abc");
+
+ StripeInvoiceDao.InvoiceRow row =
+ new StripeInvoiceDao.InvoiceRow(
+ "in_1",
+ "STIR-0001",
+ "paid",
+ 2500L,
+ "usd",
+ LocalDateTime.of(2026, 6, 1, 10, 0),
+ LocalDateTime.of(2026, 5, 1, 0, 0),
+ LocalDateTime.of(2026, 5, 31, 23, 59),
+ "https://stripe/invoice/1",
+ "https://stripe/invoice/1.pdf",
+ "Stirling Processor Plan",
+ 50000L);
+
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
+ when(extRepo.findById(7L)).thenReturn(Optional.of(ext));
+ // 1000 should clamp to MAX_LIMIT (100) inside the controller.
+ when(invoiceDao.findRecentByCustomer(eq("cus_abc"), eq(100))).thenReturn(List.of(row));
+
+ ResponseEntity> resp = controller.list(1000, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).hasSize(1);
+ InvoiceResponse body = resp.getBody().get(0);
+ assertThat(body.id()).isEqualTo("in_1");
+ assertThat(body.number()).isEqualTo("STIR-0001");
+ assertThat(body.status()).isEqualTo("paid");
+ assertThat(body.totalMinor()).isEqualTo(2500L);
+ assertThat(body.currency()).isEqualTo("usd");
+ assertThat(body.hostedInvoiceUrl()).isEqualTo("https://stripe/invoice/1");
+ assertThat(body.description()).isEqualTo("Stirling Processor Plan");
+ assertThat(body.pdfsProcessed()).isEqualTo(50000L);
+ }
+ }
+
+ @Test
+ void list_emptyDaoResult_returnsEmpty() {
+ User user = mockUser(42L);
+ TeamMembership tm = mockMembership(7L);
+ PaygTeamExtensions ext = new PaygTeamExtensions();
+ ext.setTeamId(7L);
+ ext.setStripeCustomerId("cus_xyz");
+
+ try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
+ mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
+ .thenReturn(user);
+ when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
+ when(extRepo.findById(7L)).thenReturn(Optional.of(ext));
+ when(invoiceDao.findRecentByCustomer(anyString(), anyInt())).thenReturn(List.of());
+
+ ResponseEntity> resp = controller.list(null, auth);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).isEmpty();
+ }
+ }
+
+ private static User mockUser(long id) {
+ User u = new User();
+ u.setId(id);
+ return u;
+ }
+
+ private static TeamMembership mockMembership(long teamId) {
+ Team team = new Team();
+ team.setId(teamId);
+ TeamMembership tm = new TeamMembership();
+ tm.setTeam(team);
+ return tm;
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java
new file mode 100644
index 0000000000..c4c0780b86
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java
@@ -0,0 +1,184 @@
+package stirling.software.saas.payg.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.jwt.Jwt;
+
+import stirling.software.common.model.enumeration.TeamRole;
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.payg.api.PaygPaymentMethodController.PaymentMethodResponse;
+import stirling.software.saas.payg.policy.PaygTeamExtensions;
+import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
+import stirling.software.saas.payg.stripe.StripePaymentMethodDao;
+import stirling.software.saas.payg.stripe.StripePaymentMethodDao.CardSummary;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
+
+/**
+ * Pure-Mockito unit tests for {@link PaygPaymentMethodController}: the auth/team-resolution and
+ * defensive-degrade branches, plus the happy path mapping a DAO {@link CardSummary} to the trimmed
+ * response.
+ */
+@ExtendWith(MockitoExtension.class)
+class PaygPaymentMethodControllerTest {
+
+ @Mock private StripePaymentMethodDao paymentMethodDao;
+ @Mock private PaygTeamExtensionsRepository extRepo;
+ @Mock private TeamMembershipRepository memberRepo;
+ @Mock private UserRepository userRepository;
+
+ private PaygPaymentMethodController controller;
+
+ @BeforeEach
+ void setUp() {
+ controller =
+ new PaygPaymentMethodController(
+ paymentMethodDao, extRepo, memberRepo, userRepository);
+ }
+
+ @Test
+ void anonymousIsRejected() {
+ Authentication anon =
+ new AnonymousAuthenticationToken(
+ "k",
+ "anonymousUser",
+ List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
+
+ ResponseEntity resp = controller.get(anon);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
+ verifyNoInteractions(paymentMethodDao, extRepo, memberRepo);
+ }
+
+ @Test
+ void noTeam_returnsAbsent() {
+ User user = userWithId(5L, UUID.randomUUID());
+ when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
+ when(memberRepo.findPrimaryMembership(5L)).thenReturn(List.of());
+
+ ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId()));
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().present()).isFalse();
+ verifyNoInteractions(paymentMethodDao);
+ }
+
+ @Test
+ void noStripeCustomer_returnsAbsent() {
+ User user = userWithId(6L, UUID.randomUUID());
+ Team team = teamWithId(60L);
+ when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
+ when(memberRepo.findPrimaryMembership(6L))
+ .thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
+ PaygTeamExtensions ext = mock(PaygTeamExtensions.class);
+ when(ext.getStripeCustomerId()).thenReturn(null);
+ when(extRepo.findById(60L)).thenReturn(Optional.of(ext));
+
+ ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId()));
+
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().present()).isFalse();
+ verifyNoInteractions(paymentMethodDao);
+ }
+
+ @Test
+ void cardOnFile_returnsPresentWithFields() {
+ User user = userWithId(7L, UUID.randomUUID());
+ Team team = teamWithId(70L);
+ when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
+ when(memberRepo.findPrimaryMembership(7L))
+ .thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
+ PaygTeamExtensions ext = mock(PaygTeamExtensions.class);
+ when(ext.getStripeCustomerId()).thenReturn("cus_123");
+ when(extRepo.findById(70L)).thenReturn(Optional.of(ext));
+ when(paymentMethodDao.findDefaultCard("cus_123"))
+ .thenReturn(Optional.of(new CardSummary("visa", "4242", 8, 2027)));
+
+ ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId()));
+
+ PaymentMethodResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.present()).isTrue();
+ assertThat(body.brand()).isEqualTo("visa");
+ assertThat(body.last4()).isEqualTo("4242");
+ assertThat(body.expMonth()).isEqualTo(8);
+ assertThat(body.expYear()).isEqualTo(2027);
+ }
+
+ @Test
+ void mirrorMissingCard_returnsAbsent() {
+ User user = userWithId(8L, UUID.randomUUID());
+ Team team = teamWithId(80L);
+ when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
+ when(memberRepo.findPrimaryMembership(8L))
+ .thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
+ PaygTeamExtensions ext = mock(PaygTeamExtensions.class);
+ when(ext.getStripeCustomerId()).thenReturn("cus_456");
+ when(extRepo.findById(80L)).thenReturn(Optional.of(ext));
+ when(paymentMethodDao.findDefaultCard("cus_456")).thenReturn(Optional.empty());
+
+ ResponseEntity resp = controller.get(jwtAuth(user.getSupabaseId()));
+
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().present()).isFalse();
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Fixtures (mirroring PaygWalletControllerTest)
+ // -----------------------------------------------------------------------------------------
+
+ private static User userWithId(Long id, UUID supabaseId) {
+ User u = new User();
+ u.setId(id);
+ u.setSupabaseId(supabaseId);
+ return u;
+ }
+
+ private static Team teamWithId(Long id) {
+ Team t = new Team();
+ t.setId(id);
+ t.setName("t-" + id);
+ return t;
+ }
+
+ private static TeamMembership membership(Team team, User user, TeamRole role) {
+ TeamMembership m = new TeamMembership();
+ m.setTeam(team);
+ m.setUser(user);
+ m.setRole(role);
+ return m;
+ }
+
+ private static Authentication jwtAuth(UUID supabaseId) {
+ Jwt jwt =
+ Jwt.withTokenValue("token")
+ .header("alg", "RS256")
+ .claim("sub", supabaseId.toString())
+ .claim("email", "user@example.com")
+ .build();
+ return new EnhancedJwtAuthenticationToken(
+ jwt, List.of(), "user@example.com", supabaseId.toString());
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java
index 2118f85794..39c244f762 100644
--- a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java
+++ b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java
@@ -29,10 +29,22 @@ class CapEvaluatorTest {
}
@Test
- void zeroCap_treatedAsUnlimitedForSafety() {
- // Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL).
+ void zeroCap_blocksMeteredWork() {
+ // An explicit $0 cap buys zero paid documents → metered work is blocked
+ // (DEGRADED/MINIMAL); only the free grant + manual tools run. (Uncapped is the
+ // separate capUnits==null case, covered by nullCap_returnsFullStateAndFullGates.)
Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL);
- assertThat(e.state()).isEqualTo(EntitlementState.FULL);
+ assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
+ assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL);
+ assertThat(e.enabledGates())
+ .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
+ }
+
+ @Test
+ void zeroCap_blocksEvenAtZeroSpend() {
+ // A $0 cap blocks from the first metered op — not gated on spend.
+ Evaluation e = CapEvaluator.evaluate(0L, 0L, 80, 100, FeatureSet.MINIMAL);
+ assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
}
@Test
diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java
index 1aa9e783b6..54b6e56391 100644
--- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java
+++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java
@@ -243,6 +243,7 @@ class SupabaseSecurityConfigMoreTest {
@Test
@DisplayName("builds and returns the SecurityFilterChain from http.build()")
+ @SuppressWarnings("unchecked")
void buildsFilterChain() throws Exception {
HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS);
// http.build() returns DefaultSecurityFilterChain, so stub with that concrete type.
@@ -250,8 +251,16 @@ class SupabaseSecurityConfigMoreTest {
mock(org.springframework.security.web.DefaultSecurityFilterChain.class);
when(http.build()).thenReturn(built);
+ // Device-credential filter is wired via an ObjectProvider; getIfAvailable() returns
+ // null here, so the optional filter is simply not added (fine for a build-only check).
+ org.springframework.beans.factory.ObjectProvider<
+ stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter>
+ deviceFilterProvider =
+ mock(org.springframework.beans.factory.ObjectProvider.class);
+
SecurityFilterChain result =
- config(new ApplicationProperties()).saasSecurityFilterChain(http, jwtDecoder);
+ config(new ApplicationProperties())
+ .saasSecurityFilterChain(http, jwtDecoder, deviceFilterProvider);
assertThat(result).isSameAs(built);
}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java
index 31c4f4be5c..ac2a4cecba 100644
--- a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java
+++ b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java
@@ -23,6 +23,8 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
import stirling.software.common.model.enumeration.InvitationStatus;
import stirling.software.common.model.enumeration.Role;
@@ -32,6 +34,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
+import stirling.software.saas.accountlink.LinkedInstanceRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamInvitation;
@@ -62,6 +65,7 @@ class SaasTeamServiceTest {
@Mock private UserRoleService userRoleService;
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository;
+ @Mock private LinkedInstanceRepository linkedInstanceRepository;
@Mock private stirling.software.proprietary.security.service.UserService userService;
@InjectMocks private SaasTeamService service;
@@ -1383,4 +1387,92 @@ class SaasTeamServiceTest {
return saved;
});
}
+
+ /**
+ * acceptInvitation's orphan guard against linked self-hosted instances (combined-billing "Mode
+ * A"). The guard ({@code assertCanLeaveCurrentTeamsToJoinAnother}) is private; it's exercised
+ * through its only caller up to the point where a team with active linked instances must block
+ * the move. LENIENT because the pass-through case stubs the full leave/join path while the
+ * blocking case short-circuits before reaching all of it.
+ */
+ @Nested
+ @DisplayName("acceptInvitation - linked self-hosted instance orphan guard")
+ @MockitoSettings(strictness = Strictness.LENIENT)
+ class AcceptInvitationLinkedInstanceGuard {
+
+ private static final long USER_ID = 7L;
+ private static final long OLD_TEAM_ID = 100L;
+ private static final long NEW_TEAM_ID = 200L;
+ private static final String TOKEN = "tok-1";
+ private static final String EMAIL = "joiner@example.com";
+
+ @Test
+ @DisplayName("blocks accept when the current team has active linked instances")
+ void blocksWhenCurrentTeamHasActiveLinkedInstances() {
+ User joiner = user(USER_ID, EMAIL, EMAIL);
+ Team oldTeam = team(OLD_TEAM_ID, "old-team");
+ Team newTeam = team(NEW_TEAM_ID, "new-team");
+ TeamInvitation invitation = pendingInvitation(newTeam, joiner);
+
+ when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner));
+ when(invitationRepository.findByInvitationToken(TOKEN))
+ .thenReturn(Optional.of(invitation));
+ when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
+ when(membershipRepository.findByUserId(USER_ID))
+ .thenReturn(List.of(membership(oldTeam, joiner, TeamRole.LEADER)));
+ when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID))
+ .thenReturn(1L);
+
+ assertThatThrownBy(() -> service.acceptInvitation(TOKEN, joiner))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage(
+ "Revoke linked self-hosted instances on this team before joining another"
+ + " team.");
+
+ // Guard fires before any team mutation.
+ verify(membershipRepository, never()).delete(any());
+ verify(userRepository, never()).updateUserTeamId(anyLong(), anyLong());
+ }
+
+ @Test
+ @DisplayName("lets accept through when the current team has no linked instances")
+ void passesGuardWhenNoLinkedInstances() {
+ User joiner = user(USER_ID, EMAIL, EMAIL);
+ Team oldTeam = team(OLD_TEAM_ID, "old-team");
+ Team newTeam = team(NEW_TEAM_ID, "new-team");
+ TeamInvitation invitation = pendingInvitation(newTeam, joiner);
+ TeamMembership oldMembership = membership(oldTeam, joiner, TeamRole.LEADER);
+
+ when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner));
+ when(invitationRepository.findByInvitationToken(TOKEN))
+ .thenReturn(Optional.of(invitation));
+ when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true);
+ when(membershipRepository.findByUserId(USER_ID)).thenReturn(List.of(oldMembership));
+ when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID))
+ .thenReturn(0L);
+ // Personal old team → guard skips the last-leader check and leave/join proceeds.
+ when(saasTeamExtensionService.isPersonal(oldTeam)).thenReturn(true);
+ when(membershipRepository.countByTeamId(OLD_TEAM_ID)).thenReturn(0L);
+ when(saasTeamExtensionsRepository.incrementSeatsUsed(NEW_TEAM_ID)).thenReturn(1);
+
+ service.acceptInvitation(TOKEN, joiner);
+
+ // Guard let the move through: the old membership was left and the user re-pointed.
+ verify(membershipRepository).delete(oldMembership);
+ verify(userRepository).updateUserTeamId(USER_ID, NEW_TEAM_ID);
+ verify(invitationRepository).save(invitation);
+ assertThat(invitation.getStatus()).isEqualTo(InvitationStatus.ACCEPTED);
+ }
+
+ private TeamInvitation pendingInvitation(Team team, User invitee) {
+ TeamInvitation inv = new TeamInvitation();
+ inv.setTeam(team);
+ inv.setInviter(invitee);
+ inv.setInviteeEmail(invitee.getEmail());
+ inv.setStatus(InvitationStatus.PENDING);
+ inv.setInvitationToken(TOKEN);
+ inv.setExpiresAt(LocalDateTime.now().plusDays(1));
+ return inv;
+ }
+ }
}
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 6d958ac439..3a5de11c57 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -24,10 +24,10 @@
/editor/.env.local
/editor/.env.*.local
-# Root .gitignore ignores all .env* - whitelist our committed ones here
-!.env
-!.env.desktop
-!.env.saas
+# Root .gitignore ignores all .env* - whitelist only our committed ones, anchored
+# to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS
+# mock env is injected via .storybook/main.ts, not a file).
+!/portal/.env
!/editor/.env
!/editor/.env.desktop
!/editor/.env.saas
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
index 0797a367a3..c016a5c37d 100644
--- a/frontend/.storybook/main.ts
+++ b/frontend/.storybook/main.ts
@@ -51,6 +51,16 @@ const config: StorybookConfig = {
],
}),
);
+ // Point apiClient.saas at a mock origin so the SaaS-backed billing stories
+ // (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and
+ // their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host
+ // never receives a real request — MSW answers first. Injected here, next to the
+ // MSW setup, rather than via a frontend/.env so no stray env file can leak into a
+ // real portal/editor build (those load env from their own roots).
+ config.define = {
+ ...(config.define ?? {}),
+ "import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
+ };
return config;
},
};
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index c94b169737..1f9c935fe6 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -14,10 +14,12 @@ import { MantineProvider } from "@mantine/core";
void React;
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
+import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { handlers } from "@portal/mocks/handlers";
+import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
import "@mantine/core/styles.css";
import "@shared/tokens/tokens.css";
@@ -26,6 +28,27 @@ import "@shared/tokens/base.css";
// Start MSW once. Storybook runs in a browser so this uses the service worker.
initialize({ onUnhandledRequest: "bypass" }, handlers);
+// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
+// method, wallet) clear the session check and reach the MSW handlers instead of
+// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
+// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
+// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
+// .storybook/main.ts's viteFinal define, not a frontend/.env file.
+const saasStub = configureSupabase({
+ url: "http://saas.mock",
+ key: "storybook-anon-key",
+ authOptions: {
+ persistSession: false,
+ autoRefreshToken: false,
+ detectSessionInUrl: false,
+ },
+});
+saasStub.auth.getSession = async () =>
+ ({
+ data: { session: { access_token: "storybook-fake-jwt" } },
+ error: null,
+ }) as Awaited>;
+
/**
* Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
* Without this the toolbar would just change a label; with it, every story
@@ -67,6 +90,8 @@ function ThemeWatcher() {
const withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro";
+ const linkState =
+ (context.globals.linkState as LinkState) ?? "linked-subscribed";
// withThemeByDataAttribute exposes the toolbar theme as the `theme` global.
// Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings,
// default surfaces) follows the dark toggle alongside the SUI CSS variables.
@@ -78,12 +103,16 @@ const withProviders: Decorator = (Story, context) => {
-
-
-
-
-
-
+ {/* LinkProvider must wrap TierProvider: TierContext derives its tier
+ from useLink() (matches App.tsx's nesting). */}
+
+
+
+
+
+
+
+
@@ -128,6 +157,20 @@ const preview: Preview = {
dynamicTitle: true,
},
},
+ linkState: {
+ name: "Link",
+ description: "Account-link state — drives useLink() everywhere",
+ defaultValue: "linked-subscribed",
+ toolbar: {
+ icon: "link",
+ items: [
+ { value: "unlinked", title: "Unlinked" },
+ { value: "linked-free", title: "Linked · Free" },
+ { value: "linked-subscribed", title: "Linked · PAYG" },
+ ],
+ dynamicTitle: true,
+ },
+ },
},
decorators: [
withProviders,
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx
index bee9d0474d..f26d40f743 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx
@@ -1,250 +1,59 @@
/**
- * Reusable monthly spend-cap control.
- *
- * One inline row — preset chips, a custom-entry pill that matches the presets,
- * a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs /
- * month" estimate. Extracted from the subscribed plan-page cap editor so the
- * exact same control drives the upgrade checkout flow.
- *
- *
Currency-agnostic by design
- *
- * The control never decides a currency. It takes {@code pricePerDocMinor} +
- * {@code currency} and renders whatever it's handed: the subscribed plan page
- * passes the team's real Stripe-subscription rate/currency; the unsubscribed
- * checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
- * yet) plus a {@code note} explaining the cap is editable later. When no rate
- * is supplied the estimate simply hides.
- *
- *
Controlled
- *
- * Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
- * real $0 cap that keeps everything free) + {@code onChange}. The parent owns
- * the working value. When {@code onSave} is provided the control renders the
- * inline Save button and computes "dirty" against {@code savedCapUsd}.
+ * Editor cloud adapter over the shared {@code @shared/billing} spend-cap control:
+ * supplies the i18n copy (the shared control is copy-agnostic) and the editor's
+ * {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code
+ * onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is
+ * unchanged, so the plan-page cap editor and the upgrade-checkout flow keep
+ * consuming it as before.
*/
-import React, { useState } from "react";
-import { Button } from "@mantine/core";
-import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
-import LocalIcon from "@app/components/shared/LocalIcon";
+import React from "react";
import { useTranslation } from "react-i18next";
+import {
+ DEFAULT_CAP_PRESETS,
+ SpendCapControl as SharedSpendCapControl,
+} from "@shared/billing";
// eslint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
-// Quick amounts offered everywhere — recognition over recall.
-export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
+export { DEFAULT_CAP_PRESETS };
export interface SpendCapControlProps {
- /** Current cap in major currency units; {@code null} = no cap. Controlled. */
capUsd: number | null;
- /** Working-value setter. {@code null} signals no-cap. */
onChange: (capUsd: number | null) => void;
- /** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
pricePerDocMinor?: number | null;
- /** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
currency?: string | null;
- /** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
presets?: readonly number[];
- /**
- * When provided, the control renders an inline Save button. Receives whole
- * major units, or {@code null} for no-cap.
- */
onSave?: (capUsd: number | null) => Promise | void;
- /** Label for the Save button. */
saveLabel?: string;
- /**
- * The persisted value to diff against for the dirty check. Same encoding as
- * {@link #capUsd} ({@code null} = persisted no-cap). Only used with
- * {@link #onSave}.
- */
savedCapUsd?: number | null;
- /** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
note?: React.ReactNode;
}
-/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
-function formatMinor(
- minor: number,
- currency: string | null | undefined,
-): string {
- const code = (currency ?? "usd").toUpperCase();
- try {
- return new Intl.NumberFormat(undefined, {
- style: "currency",
- currency: code,
- // Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
- // rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
- maximumFractionDigits: 3,
- }).format(minor / 100);
- } catch {
- return `${(minor / 100).toFixed(2)} ${code}`;
- }
-}
-
-/** Currency symbol for compact inline use; falls back to the ISO code. */
-function currencySymbol(currency: string | null | undefined): string {
- switch ((currency ?? "").toLowerCase()) {
- case "usd":
- case "":
- return "$";
- case "eur":
- return "€";
- case "gbp":
- return "£";
- default:
- return currency!.toUpperCase() + " ";
- }
-}
-
const SpendCapControl: React.FC = ({
- capUsd,
- onChange,
- pricePerDocMinor,
- currency,
- presets = DEFAULT_CAP_PRESETS,
- onSave,
saveLabel,
- savedCapUsd,
- note,
+ ...rest
}) => {
const { t } = useTranslation();
- const [saving, setSaving] = useState(false);
-
- const sym = currencySymbol(currency);
- const isNoCap = capUsd === null;
- const presetSelected = capUsd != null && presets.includes(capUsd);
- // Custom is "active" when a cap is set that isn't one of the presets — i.e.
- // the value came from the custom pill.
- const customActive = capUsd != null && !presets.includes(capUsd);
-
- // Local mirror of the custom field's text so partial/empty entry doesn't get
- // clobbered by the controlled value. Seeded from a non-preset incoming cap.
- const [customText, setCustomText] = useState(
- customActive ? String(capUsd) : "",
- );
-
- // Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
- // one-time free grant is a separate lifetime pool and is NOT added here —
- // this is the paid PDFs the monthly cap buys.
- const rate =
- pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
- const previewDocs =
- capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
-
- const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
-
- const selectPreset = (preset: number) => {
- setCustomText("");
- onChange(preset);
- };
- const selectNoCap = () => {
- setCustomText("");
- onChange(null);
- };
- const onCustomInput = (raw: string) => {
- // Digits only; an empty field reads as "no custom value yet" → 0 so the
- // estimate still renders sensibly without flipping to no-cap.
- const cleaned = raw.replace(/[^0-9]/g, "");
- setCustomText(cleaned);
- const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
- onChange(Number.isNaN(v) ? 0 : v);
- };
-
- const handleSave = async () => {
- if (!onSave) return;
- setSaving(true);
- try {
- await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
- } finally {
- setSaving(false);
- }
- };
-
return (
-
-
- {presets.map((preset) => (
-
- ))}
-
- {/* Custom-entry pill — dashed until it carries a value, then it fills
- like a selected chip. */}
-
-
-
-
- {onSave && (
- }
- onClick={handleSave}
- >
- {saveLabel ?? t("payg.cap.save", "Update cap")}
-
- )}
-