{@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.
+ *
{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
+ * cumulative units and returns the refreshed entitlement.
+ *
{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
+ * /api/v1/instance/revoke-self}).
*
*
- *
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.
+ *
Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
+ * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
*/
@Slf4j
@Service
@@ -86,11 +93,9 @@ public int 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.
+ * Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
+ * transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
+ * this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
*/
public static final class RevokedException extends RuntimeException {
private final int status;
@@ -142,11 +147,9 @@ public RegisterResult register(String supabaseJwt, String instanceName) throws I
}
/**
- * 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).
+ * Revokes this instance's own credential on the SaaS side, authenticated by that credential.
+ * Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
+ * unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
@@ -218,6 +221,63 @@ public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret
}
}
+ /**
+ * Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
+ * returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
+ * SaaS bills the delta against its last-seen cumulative, so resending the same totals is
+ * idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
+ * not advance its last-synced markers so the usage retries next sync.
+ */
+ public InstanceEntitlement reportUsage(
+ String deviceId,
+ String deviceSecret,
+ long syncSeq,
+ LocalDateTime periodStart,
+ long apiUnits,
+ long aiUnits,
+ long automationUnits) {
+ HttpResponse response;
+ try {
+ ObjectNode root = mapper.createObjectNode();
+ root.put("syncSeq", syncSeq);
+ // Explicit ISO-8601 string so it round-trips regardless of the mapper's time config.
+ root.put("periodStart", periodStart.toString());
+ ObjectNode units = root.putObject("cumulativeUnits");
+ units.put("api", apiUnits);
+ units.put("ai", aiUnits);
+ units.put("automation", automationUnits);
+ String body = mapper.writeValueAsString(root);
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(uri("/api/v1/instance/sync"))
+ .header(HEADER_DEVICE_ID, deviceId)
+ .header(HEADER_DEVICE_SECRET, deviceSecret)
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .timeout(timeout())
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build();
+ response = send(request);
+ } catch (Exception e) {
+ log.debug("Usage sync failed: {}", e.getMessage());
+ return null;
+ }
+ int status = response.statusCode();
+ if (status == 401 || status == 403) {
+ throw new RevokedException(status);
+ }
+ if (status / 100 != 2) {
+ log.debug("Usage sync returned HTTP {}", status);
+ return null;
+ }
+ try {
+ return parseEntitlement(response.body());
+ } catch (IOException e) {
+ log.debug("Usage sync parse failed: {}", e.getMessage());
+ return null;
+ }
+ }
+
private InstanceEntitlement parseEntitlement(String body) throws IOException {
JsonNode root = mapper.readTree(body);
boolean subscribed = root.path("subscribed").asBoolean(false);
@@ -226,7 +286,45 @@ private InstanceEntitlement parseEntitlement(String body) throws IOException {
Long periodCap =
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
EntitlementState state = mapState(root.path("state").asText(null));
- return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
+ return new InstanceEntitlement(
+ subscribed,
+ freeRemaining,
+ periodSpend,
+ periodCap,
+ state,
+ parseUnitCalcPolicy(root),
+ parseDateTime(root, "periodStart"),
+ parseDateTime(root, "periodEnd"));
+ }
+
+ /** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */
+ private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) {
+ if (!root.hasNonNull("unitCalcPolicy")) {
+ return null;
+ }
+ JsonNode node = root.get("unitCalcPolicy");
+ try {
+ return new UnitCalcPolicy(
+ node.path("docPagesPerUnit").asInt(),
+ node.path("docBytesPerUnit").asLong(),
+ node.path("minChargeUnits").asInt(),
+ node.path("fileUnitCap").asInt());
+ } catch (RuntimeException e) {
+ // Malformed policy → degrade to "none" rather than fail the whole entitlement parse.
+ return null;
+ }
+ }
+
+ /** ISO date-time field → LocalDateTime; null if absent or unparseable. */
+ private static LocalDateTime parseDateTime(JsonNode root, String field) {
+ if (!root.hasNonNull(field)) {
+ return null;
+ }
+ try {
+ return LocalDateTime.parse(root.get(field).asText(null));
+ } catch (RuntimeException e) {
+ return null;
+ }
}
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java
index 1d1a8aa77d..52af366df4 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java
@@ -2,6 +2,7 @@
import java.io.IOException;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
@@ -23,7 +24,9 @@
*
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.
+ * the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
+ * to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
+ * / test aid).
*
*
Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
@@ -38,9 +41,17 @@
public class AccountLinkController {
private final AccountLinkService service;
+ private final LocalUsageService localUsageService;
+ // Present only when metering is on (its own flag); absent → /sync-now reports 409.
+ private final ObjectProvider syncServiceProvider;
- public AccountLinkController(AccountLinkService service) {
+ public AccountLinkController(
+ AccountLinkService service,
+ LocalUsageService localUsageService,
+ ObjectProvider syncServiceProvider) {
this.service = service;
+ this.localUsageService = localUsageService;
+ this.syncServiceProvider = syncServiceProvider;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
@@ -85,4 +96,29 @@ public ResponseEntity unlink() {
service.unlink();
return ResponseEntity.noContent().build();
}
+
+ /**
+ * Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend
+ * so "current usage" includes work done since the last daily sync.
+ */
+ @GetMapping("/usage")
+ public ResponseEntity usage() {
+ return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
+ }
+
+ /**
+ * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
+ * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
+ * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
+ * {@code 409} when metering is off (the sync bean is absent).
+ */
+ @PostMapping("/sync-now")
+ public ResponseEntity syncNow() {
+ UsageSyncService sync = syncServiceProvider.getIfAvailable();
+ if (sync == null) {
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
+ }
+ sync.syncNow();
+ 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
index c12e56f06d..6d1f1fb151 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java
@@ -1,5 +1,7 @@
package stirling.software.proprietary.accountlink;
+import java.time.Duration;
+
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@@ -36,4 +38,39 @@ public class AccountLinkProperties {
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
+
+ /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
+ private final Metering metering = new Metering();
+
+ /**
+ * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be
+ * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
+ * enforcement. Both default off; metering requires the master flag too. This is the production
+ * safety key — flipping it on is what actually bills linked instances.
+ */
+ @Getter
+ @Setter
+ public static class Metering {
+
+ /** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
+ private boolean enabled = false;
+
+ /**
+ * How often the instance syncs usage + refreshes entitlement (matches the licence sync).
+ */
+ private int syncIntervalHours = 24;
+
+ /**
+ * Block billable work after this many days with no successful sync (fail-open → closed).
+ */
+ private int graceDays = 3;
+
+ /**
+ * Dedup window for identical input sets. A re-run of the same inputs within this window is
+ * treated as workflow chaining and not re-charged; the same inputs run again after it are
+ * billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
+ * costs the same on the instance and in the cloud.
+ */
+ private Duration workflowWindow = Duration.ofMinutes(5);
+ }
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java
new file mode 100644
index 0000000000..fbac6a8603
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java
@@ -0,0 +1,46 @@
+package stirling.software.proprietary.accountlink;
+
+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;
+
+/**
+ * Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
+ *
+ *
{@link #lastSyncSeq} is reserved (incremented + persisted) before each report so it
+ * is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
+ * so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last
+ * sync SaaS accepted and drives the fail-open→closed grace window.
+ *
+ *
Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync.
+ */
+@Entity
+@Table(name = "account_link_sync_state")
+@Getter
+@Setter
+@NoArgsConstructor
+public class AccountLinkSyncState {
+
+ /** One instance links to one team → one bookkeeping row. */
+ public static final long SINGLETON_ID = 1L;
+
+ @Id private Long id;
+
+ // columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres.
+ @Column(
+ name = "last_sync_seq",
+ nullable = false,
+ columnDefinition = "bigint not null default 0")
+ private long lastSyncSeq;
+
+ /** Null until the first sync SaaS accepts. */
+ @Column(name = "last_success_at")
+ private LocalDateTime lastSuccessAt;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java
new file mode 100644
index 0000000000..15b5e3842d
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java
@@ -0,0 +1,6 @@
+package stirling.software.proprietary.accountlink;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
+public interface AccountLinkSyncStateRepository extends JpaRepository {}
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
index 136e4c3214..476fbd111a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java
@@ -3,14 +3,26 @@
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.service.InternalApiClient;
+import stirling.software.proprietary.billing.BillingCategory;
+import stirling.software.proprietary.billing.BillingCategoryClassifier;
/**
- * Classifies a request as billable (AI / automation) or free (a manual tool).
+ * Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only
+ * HTTP-level signals (no dependency on the saas module):
*
- *
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.
+ *
+ *
AUTOMATION — the automation marker header ({@link
+ * InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
+ *
AI — the AI surface ({@code /api/v1/ai/**});
+ *
API — an API-key authenticated tool call;
+ *
BYPASSED — a manual interactive tool call, never billed.
+ *
+ *
+ *
Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared
+ * {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the
+ * saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the
+ * caller (resolved from the security context), so this class stays free of any security-type
+ * dependency.
*/
public final class BillableOperationClassifier {
@@ -18,16 +30,22 @@ public final class BillableOperationClassifier {
private BillableOperationClassifier() {}
- public static boolean isBillable(HttpServletRequest request) {
- if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
- return true;
- }
+ /**
+ * @param apiKey whether the request authenticated via an API key (an {@code
+ * ApiKeyAuthenticationToken} principal), resolved by the caller from the security context.
+ */
+ public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) {
+ boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null;
+ return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey);
+ }
+
+ private static boolean isAiSurface(HttpServletRequest request) {
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.
+ // context path so //api/v1/ai/** still classifies as AI.
String ctx = request.getContextPath();
String path =
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
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
index 63818c1f98..898fb54b3a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java
@@ -12,18 +12,13 @@
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.
+ * Caches the linked team's entitlement so the request-time gate needn't call SaaS 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.
+ *
A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has
+ * and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An
+ * authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is
+ * replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately.
*/
@Slf4j
@Service
@@ -63,9 +58,8 @@ public EntitlementCache(
* 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.
+ // Single-flight: when stale, exactly one thread refreshes while concurrent callers serve
+ // the last snapshot — no thundering herd of round-trips on the billable hot path.
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
try {
refresh();
@@ -77,16 +71,15 @@ public Optional current() {
}
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.
+ // fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch
+ // backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS.
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.
+ * Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the
+ * attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a
+ * blocked snapshot.
*/
void refresh() {
Optional cred = credentialStore.get();
@@ -101,15 +94,15 @@ void refresh() {
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.
+ // Unreachable / server error: keep the last known entitlement but stamp the attempt
+ // so we don't hammer SaaS; the gate fails open meanwhile.
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.
+ // Authoritative deny — block immediately rather than serving the stale entitled
+ // snapshot.
log.info(
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
e.status());
@@ -121,4 +114,14 @@ void refresh() {
public void invalidate() {
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
}
+
+ /**
+ * Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh
+ * one), saving a redundant fetch. No-op on null.
+ */
+ public void accept(InstanceEntitlement fresh) {
+ if (fresh != null) {
+ snapshot = new Snapshot(fresh, Instant.now());
+ }
+ }
}
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
index 677183278b..87b4ddcde9 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java
@@ -16,6 +16,11 @@ public enum Reason {
ENTITLED,
/** Entitlement source unreachable — fail open, allow. */
FAIL_OPEN,
+ /**
+ * Linked + metering, but SaaS has been unreachable past the grace window — block (the
+ * fail-open backstop expired) so unbounded free/unbilled billable work can't continue.
+ */
+ GRACE_EXPIRED,
/** Not linked — block billable work; FE should prompt to link. */
NOT_LINKED,
/** Linked but over the limit / no subscription — block billable work. */
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
index 6445d886e9..8760239957 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java
@@ -1,19 +1,53 @@
package stirling.software.proprietary.accountlink;
+import java.time.LocalDateTime;
+
+import stirling.software.proprietary.billing.UnitCalcPolicy;
+
/**
- * 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.
+ * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response.
+ * Mirrors the saas {@code EntitlementResponse} shape but carries no saas types.
+ *
+ *
The first five fields are what the gate enforces against; the trailing three are the
+ * metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its
+ * per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the
+ * revoked sentinel and unit tests that don't exercise metering.
*
* @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})
+ * @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not
+ * supplied (older SaaS / gate-only sentinel)
+ * @param periodStart inclusive start of the current billing period; {@code null} if not supplied
+ * @param periodEnd exclusive end of the current billing period; {@code null} if not supplied
*/
public record InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
- EntitlementState state) {}
+ EntitlementState state,
+ UnitCalcPolicy unitCalcPolicy,
+ LocalDateTime periodStart,
+ LocalDateTime periodEnd) {
+
+ /** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */
+ public InstanceEntitlement(
+ boolean subscribed,
+ long freeRemainingUnits,
+ long periodSpendUnits,
+ Long periodCapUnits,
+ EntitlementState state) {
+ this(
+ subscribed,
+ freeRemainingUnits,
+ periodSpendUnits,
+ periodCapUnits,
+ state,
+ null,
+ null,
+ null);
+ }
+}
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
index c975bad055..018684b73f 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java
@@ -1,5 +1,6 @@
package stirling.software.proprietary.accountlink;
+import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -15,14 +16,17 @@
*
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 + entitlement unknown (unreachable) → fail open, allow — unless
+ * metering is on and SaaS has been unreachable past the grace window, then block with {@code
+ * GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever.
*
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.
+ *
The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live
+ * flag / linked-state / entitlement and computes whether the grace window has expired. This is the
+ * unit-tested core.
*/
@Service
@Profile("!saas")
@@ -32,14 +36,20 @@ public class InstanceEntitlementGate {
private final AccountLinkProperties properties;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
+ private final AccountLinkSyncStateRepository syncStateRepository;
+ private final LocalUsageService localUsageService;
public InstanceEntitlementGate(
AccountLinkProperties properties,
DeviceCredentialStore credentialStore,
- EntitlementCache entitlementCache) {
+ EntitlementCache entitlementCache,
+ AccountLinkSyncStateRepository syncStateRepository,
+ LocalUsageService localUsageService) {
this.properties = properties;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
+ this.syncStateRepository = syncStateRepository;
+ this.localUsageService = localUsageService;
}
/** Evaluates the gate for a request, resolving live state from the store + cache. */
@@ -53,18 +63,39 @@ public GateDecision evaluate(boolean billable) {
boolean linked = credentialStore.isLinked();
Optional entitlement =
linked ? entitlementCache.current() : Optional.empty();
- return decide(true, true, linked, entitlement);
+ boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired();
+ // Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped
+ // subscription) — by local usage not yet synced, so the gate stops in real time instead of
+ // overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0.
+ long pendingUnsynced =
+ entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false)
+ ? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits()
+ : 0L;
+ return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced);
+ }
+
+ /** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */
+ private static boolean depletesCeiling(InstanceEntitlement e) {
+ return !e.subscribed() || e.periodCapUnits() != null;
}
/**
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
- * (unreachable): when linked, that fails open.
+ * (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace
+ * window elapsed with no authoritative contact), in which case it blocks.
+ *
+ * @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the
+ * free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate
+ * stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement
+ * cases, where it has no effect).
*/
public static GateDecision decide(
boolean flagEnabled,
boolean billable,
boolean linked,
- Optional entitlement) {
+ Optional entitlement,
+ boolean graceExpired,
+ long pendingUnsyncedUnits) {
if (!flagEnabled) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
@@ -75,30 +106,69 @@ public static GateDecision decide(
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);
+ // Linked but entitlement unreachable: fail open, unless the grace window has expired
+ // (so
+ // the fail-open can't grant unbounded unbilled work forever).
+ return graceExpired
+ ? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED)
+ : 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)
+ return entitled(e, pendingUnsyncedUnits)
? GateDecision.allow(GateDecision.Reason.ENTITLED)
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
}
+ /**
+ * True when metering is on and it's been {@code graceDays} since the last authoritative contact
+ * (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off
+ * disables the backstop.
+ */
+ private boolean isGraceExpired() {
+ AccountLinkProperties.Metering metering = properties.getMetering();
+ if (!metering.isEnabled() || metering.getGraceDays() <= 0) {
+ return false;
+ }
+ LocalDateTime reference = lastAuthoritativeContact();
+ if (reference == null) {
+ return false; // can't determine elapsed time → fail open
+ }
+ return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now());
+ }
+
+ private LocalDateTime lastAuthoritativeContact() {
+ LocalDateTime lastSuccess =
+ syncStateRepository
+ .findById(AccountLinkSyncState.SINGLETON_ID)
+ .map(AccountLinkSyncState::getLastSuccessAt)
+ .orElse(null);
+ if (lastSuccess != null) {
+ return lastSuccess;
+ }
+ return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null);
+ }
+
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
- private static boolean entitled(InstanceEntitlement e) {
+ private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) {
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();
+ if (e.periodCapUnits() == null) {
+ return true; // uncapped subscription
+ }
+ // Project the cap the way the grant is projected: synced paid spend plus the paid part
+ // of local usage not yet synced (free grant is consumed first, so only the excess
+ // bills) — stops at the cap in real time instead of overshooting until the next sync.
+ long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits());
+ return e.periodSpendUnits() + pendingPaid < e.periodCapUnits();
}
- // Unsubscribed: only the free pool covers billable work.
- return e.freeRemainingUnits() > 0;
+ // Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local
+ // usage not yet synced — deplete by the pending delta so we stop at the grant in real time.
+ return e.freeRemainingUnits() - pendingUnsyncedUnits > 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
index 8597813a85..285943ee49 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java
@@ -1,27 +1,51 @@
package stirling.software.proprietary.accountlink;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.DigestOutputStream;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.util.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
+import stirling.software.common.util.TempFile;
+import stirling.software.common.util.TempFileManager;
+import stirling.software.jpdfium.PdfDocument;
+import stirling.software.proprietary.billing.BillingCategory;
+import stirling.software.proprietary.billing.ContentHasher;
+import stirling.software.proprietary.billing.DocumentUnitCalculator;
+import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
+import stirling.software.proprietary.billing.UnitCalcPolicy;
+import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
+
/**
- * 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.
+ * Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
+ * AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
+ * {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
*
- *
Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
- * {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
- * activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
- * responses). Fail-open and flag-off both let the request continue.
- *
- *
Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
- * AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
+ *
Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
+ * prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind
+ * {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link
+ * UsageMeterService} bean is absent and nothing accrues, while the gate still works.
*/
@Slf4j
@Component
@@ -29,10 +53,23 @@
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
+ private static final String ATTR_CATEGORY =
+ InstanceEntitlementInterceptor.class.getName() + ".category";
+
private final InstanceEntitlementGate gate;
+ private final EntitlementCache entitlementCache;
+ private final ObjectProvider meterProvider;
+ private final TempFileManager tempFileManager;
- public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
+ public InstanceEntitlementInterceptor(
+ InstanceEntitlementGate gate,
+ EntitlementCache entitlementCache,
+ ObjectProvider meterProvider,
+ TempFileManager tempFileManager) {
this.gate = gate;
+ this.entitlementCache = entitlementCache;
+ this.meterProvider = meterProvider;
+ this.tempFileManager = tempFileManager;
}
@Override
@@ -41,7 +78,13 @@ public boolean preHandle(
throws Exception {
GateDecision decision;
try {
- decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
+ // API-key tool calls are billable (category API); stash the category for the meter.
+ boolean apiKey =
+ SecurityContextHolder.getContext().getAuthentication()
+ instanceof ApiKeyAuthenticationToken;
+ BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
+ request.setAttribute(ATTR_CATEGORY, category);
+ decision = gate.evaluate(category != BillingCategory.BYPASSED);
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
@@ -62,4 +105,139 @@ public boolean preHandle(
+ "\"}");
return false;
}
+
+ @Override
+ public void afterCompletion(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ Object handler,
+ Exception ex) {
+ // Meter successful billable ops only.
+ if (ex != null || response.getStatus() >= 400) {
+ return;
+ }
+ UsageMeterService meter = meterProvider.getIfAvailable();
+ if (meter == null) {
+ return; // metering switch off
+ }
+ if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category)
+ || category == BillingCategory.BYPASSED) {
+ return;
+ }
+ try {
+ InstanceEntitlement ent = entitlementCache.current().orElse(null);
+ if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) {
+ // Not yet synced (no policy/period) — can't compute units; skip until next sync.
+ return;
+ }
+ meterRequest(request, category, ent, meter);
+ } catch (RuntimeException e) {
+ // Metering must never affect the response that already completed.
+ log.debug("Usage metering failed for {}", request.getRequestURI(), e);
+ }
+ }
+
+ /**
+ * Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance
+ * is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy
+ * but small PDF must be page-counted or it under-bills. A fileless op has no input identity —
+ * null signature (no dedup), billed the 1-unit floor each time.
+ */
+ private void meterRequest(
+ HttpServletRequest request,
+ BillingCategory category,
+ InstanceEntitlement ent,
+ UsageMeterService meter) {
+ UnitCalcPolicy policy = ent.unitCalcPolicy();
+ MultipartHttpServletRequest mreq =
+ WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
+ if (mreq == null) {
+ long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy);
+ meter.accrue(ent.periodStart(), category, fileless, null);
+ return;
+ }
+ List temps = new ArrayList<>();
+ try {
+ List sizes = new ArrayList<>();
+ List hashes = new ArrayList<>();
+ int fileCount = 0;
+ for (List files : mreq.getMultiFileMap().values()) {
+ for (MultipartFile f : files) {
+ fileCount++;
+ try {
+ TempFile temp = tempFileManager.createManagedTempFile(".bin");
+ temps.add(temp);
+ // Hash in the same pass that writes the temp file — one read of the upload,
+ // not a second full read just to fingerprint it.
+ MessageDigest digest = ContentHasher.newSha256();
+ try (InputStream in = f.getInputStream();
+ DigestOutputStream out =
+ new DigestOutputStream(
+ Files.newOutputStream(temp.getPath()), digest)) {
+ in.transferTo(out);
+ }
+ sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize()));
+ hashes.add(ContentHasher.toHex(digest.digest()));
+ } catch (IOException | RuntimeException perFile) {
+ // Couldn't materialise/hash this input — bill on bytes only and, by leaving
+ // it out of `hashes`, drop dedup for the whole op rather than risk a
+ // mismatch.
+ log.debug(
+ "Metering materialise/hash failed for {}; bytes-only",
+ f.getOriginalFilename());
+ sizes.add(new FileSize(0, f.getSize()));
+ }
+ }
+ }
+ long units =
+ sizes.isEmpty()
+ ? DocumentUnitCalculator.unitsForFile(0, 0, policy)
+ : DocumentUnitCalculator.unitsForGroup(sizes, policy);
+ // Only dedup when every input hashed; a partial signature could collide with a
+ // different input set, so fall back to no-dedup (bill it) if any file failed.
+ String opSignature =
+ fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null;
+ meter.accrue(ent.periodStart(), category, units, opSignature);
+ } finally {
+ for (TempFile temp : temps) {
+ try {
+ temp.close();
+ } catch (RuntimeException cleanup) {
+ log.debug("Temp file cleanup failed: {}", cleanup.getMessage());
+ }
+ }
+ }
+ }
+
+ /** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */
+ private static int pageCount(Path path, MultipartFile file) {
+ if (!isPdf(file)) {
+ return 0;
+ }
+ try (PdfDocument doc = PdfDocument.open(path)) {
+ return doc.pageCount();
+ } catch (RuntimeException e) {
+ // Malformed / encrypted → byte axis only, matching the SaaS classifier.
+ log.debug(
+ "Page count unavailable for {}; metering on bytes only",
+ file.getOriginalFilename());
+ return 0;
+ }
+ }
+
+ /** Order-independent signature of the input set: sorted per-file hashes, hashed together. */
+ private static String opSignature(List hashes) {
+ List sorted = new ArrayList<>(hashes);
+ Collections.sort(sorted);
+ return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static boolean isPdf(MultipartFile file) {
+ String contentType = file.getContentType();
+ if (contentType != null && contentType.toLowerCase().contains("pdf")) {
+ return true;
+ }
+ String name = file.getOriginalFilename();
+ return name != null && name.toLowerCase().endsWith(".pdf");
+ }
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java
new file mode 100644
index 0000000000..58d365fe28
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java
@@ -0,0 +1,59 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.LocalDateTime;
+import java.util.EnumMap;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+import stirling.software.proprietary.billing.BillingCategory;
+
+/**
+ * Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal
+ * adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync.
+ *
+ *
Unsynced per category = {@code cumulativeUnits − lastSyncedUnits} (floored at 0), scoped to
+ * the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown
+ * or metering is off.
+ */
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class LocalUsageService {
+
+ private final UsageCounterRepository counters;
+ private final EntitlementCache entitlementCache;
+
+ public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) {
+ this.counters = counters;
+ this.entitlementCache = entitlementCache;
+ }
+
+ /** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */
+ public record LocalUsage(
+ LocalDateTime periodStart,
+ long apiUnsyncedUnits,
+ long aiUnsyncedUnits,
+ long automationUnsyncedUnits,
+ long totalUnsyncedUnits) {}
+
+ public LocalUsage currentPeriodUnsynced() {
+ LocalDateTime period =
+ entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null);
+ if (period == null) {
+ return new LocalUsage(null, 0, 0, 0, 0);
+ }
+ EnumMap unsynced = new EnumMap<>(BillingCategory.class);
+ for (UsageCounter c : counters.findByPeriodStart(period)) {
+ BillingCategory cat = c.billingCategory();
+ if (cat != null && cat != BillingCategory.BYPASSED) {
+ unsynced.merge(cat, c.unsyncedUnits(), Long::sum);
+ }
+ }
+ long api = unsynced.getOrDefault(BillingCategory.API, 0L);
+ long ai = unsynced.getOrDefault(BillingCategory.AI, 0L);
+ long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L);
+ return new LocalUsage(period, api, ai, automation, api + ai + automation);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java
new file mode 100644
index 0000000000..1ed49d6a8b
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java
@@ -0,0 +1,73 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.LocalDateTime;
+
+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 jakarta.persistence.UniqueConstraint;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * The last time the instance metered a given input set this period — the local equivalent of the
+ * cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling workflow
+ * window: an identical input set re-submitted within the window (see {@link
+ * AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
+ * same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
+ * window so the same operation costs the same on the instance and in the cloud.
+ *
+ *
{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
+ * artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
+ * makes the first-sighting insert an atomic claim under concurrency.
+ *
+ *
Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter.
+ */
+@Entity
+@Table(
+ name = "account_link_metered_signature",
+ uniqueConstraints =
+ @UniqueConstraint(
+ name = "uk_account_link_metered_signature",
+ columnNames = {"period_start", "signature"}))
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class MeteredInputSignature {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "period_start", nullable = false)
+ private LocalDateTime periodStart;
+
+ /** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */
+ @Column(name = "signature", nullable = false, length = 64)
+ private String signature;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ /**
+ * When this input set was last metered — the anchor the workflow-window dedup compares against.
+ */
+ @Column(name = "last_metered_at")
+ private LocalDateTime lastMeteredAt;
+
+ public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) {
+ this.periodStart = periodStart;
+ this.signature = signature;
+ this.createdAt = at;
+ this.lastMeteredAt = at;
+ }
+
+ /** Slides the window forward — the input set was seen again. */
+ public void touch(LocalDateTime at) {
+ this.lastMeteredAt = at;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java
new file mode 100644
index 0000000000..863f503f61
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java
@@ -0,0 +1,15 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
+public interface MeteredInputSignatureRepository
+ extends JpaRepository {
+
+ /** The existing row for a seen input set, so the meter can apply the workflow-window check. */
+ Optional findByPeriodStartAndSignature(
+ LocalDateTime periodStart, String signature);
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java
new file mode 100644
index 0000000000..b90af07958
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java
@@ -0,0 +1,105 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.LocalDateTime;
+
+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 jakarta.persistence.UniqueConstraint;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import stirling.software.proprietary.billing.BillingCategory;
+
+/**
+ * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
+ * Each successful billable op increments its row; the daily sync reports the cumulative totals and
+ * SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
+ * nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
+ * category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
+ */
+@Entity
+@Table(
+ name = "account_link_usage_counter",
+ uniqueConstraints =
+ @UniqueConstraint(
+ name = "uk_usage_counter_period_category",
+ columnNames = {"period_start", "category"}))
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class UsageCounter {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * Inclusive start of the billing period this counter belongs to (from the entitlement sync).
+ */
+ @Column(name = "period_start", nullable = false)
+ private LocalDateTime periodStart;
+
+ /** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */
+ @Column(name = "category", nullable = false, length = 32)
+ private String category;
+
+ /** Running total of metered units in this period+category. */
+ @Column(name = "cumulative_units", nullable = false)
+ private long cumulativeUnits;
+
+ /**
+ * {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported
+ * usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default
+ * keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already
+ * populated (NOT NULL with no default would fail the ALTER).
+ */
+ @Column(
+ name = "last_synced_units",
+ nullable = false,
+ columnDefinition = "bigint not null default 0")
+ private long lastSyncedUnits;
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ /** Fresh-accrual row: nothing synced yet. */
+ public UsageCounter(
+ LocalDateTime periodStart,
+ String category,
+ long cumulativeUnits,
+ LocalDateTime updatedAt) {
+ this(periodStart, category, cumulativeUnits, 0L, updatedAt);
+ }
+
+ public UsageCounter(
+ LocalDateTime periodStart,
+ String category,
+ long cumulativeUnits,
+ long lastSyncedUnits,
+ LocalDateTime updatedAt) {
+ this.periodStart = periodStart;
+ this.category = category;
+ this.cumulativeUnits = cumulativeUnits;
+ this.lastSyncedUnits = lastSyncedUnits;
+ this.updatedAt = updatedAt;
+ }
+
+ /** This row's category as the enum, or {@code null} for an unrecognised stored value. */
+ public BillingCategory billingCategory() {
+ try {
+ return BillingCategory.valueOf(category);
+ } catch (IllegalArgumentException unknown) {
+ return null;
+ }
+ }
+
+ /** Units accrued but not yet accepted by SaaS (floored at 0). */
+ public long unsyncedUnits() {
+ return Math.max(0, cumulativeUnits - lastSyncedUnits);
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java
new file mode 100644
index 0000000000..2140775abc
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java
@@ -0,0 +1,57 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+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;
+
+/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
+public interface UsageCounterRepository extends JpaRepository {
+
+ /**
+ * Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated
+ * (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a
+ * read-modify-write race between concurrent billable requests.
+ */
+ @Modifying
+ @Transactional
+ @Query(
+ "UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta,"
+ + " c.updatedAt = :now"
+ + " WHERE c.periodStart = :periodStart AND c.category = :category")
+ int increment(
+ @Param("periodStart") LocalDateTime periodStart,
+ @Param("category") String category,
+ @Param("delta") long delta,
+ @Param("now") LocalDateTime now);
+
+ /** All counters for a period — the daily sync reads these to report cumulative totals. */
+ List findByPeriodStart(LocalDateTime periodStart);
+
+ /**
+ * Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each
+ * so end-of-period usage isn't stranded when the billing period rolls over between syncs.
+ */
+ @Query(
+ "SELECT DISTINCT c.periodStart FROM UsageCounter c"
+ + " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart")
+ List findPeriodsWithUnsyncedUsage();
+
+ /**
+ * Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by
+ * SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced.
+ */
+ @Modifying
+ @Transactional
+ @Query(
+ "UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits"
+ + " WHERE c.periodStart = :periodStart AND c.category = :category")
+ int markSynced(
+ @Param("periodStart") LocalDateTime periodStart,
+ @Param("category") String category,
+ @Param("syncedUnits") long syncedUnits);
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java
new file mode 100644
index 0000000000..6aa93606fb
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java
@@ -0,0 +1,115 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.proprietary.billing.BillingCategory;
+
+/**
+ * Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily
+ * sync later reports the cumulative totals to SaaS.
+ *
+ *
Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow-
+ * window} is treated as chaining and not re-charged; the same inputs run again after the window are
+ * billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the
+ * instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue}
+ * is best-effort: callers need not handle persistence errors.
+ */
+@Slf4j
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(
+ name = "stirling.billing.account-link.metering.enabled",
+ havingValue = "true")
+public class UsageMeterService {
+
+ private final UsageCounterRepository repo;
+ private final MeteredInputSignatureRepository signatureRepo;
+ private final Duration workflowWindow;
+
+ public UsageMeterService(
+ UsageCounterRepository repo,
+ MeteredInputSignatureRepository signatureRepo,
+ AccountLinkProperties properties) {
+ this.repo = repo;
+ this.signatureRepo = signatureRepo;
+ this.workflowWindow = properties.getMetering().getWorkflowWindow();
+ }
+
+ /**
+ * Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first
+ * use), unless {@code opSignature} was already metered this period. No-ops for non-billable
+ * categories, non-positive units, or a missing period.
+ */
+ public void accrue(
+ LocalDateTime periodStart, BillingCategory category, long units, String opSignature) {
+ if (periodStart == null
+ || category == null
+ || category == BillingCategory.BYPASSED
+ || units <= 0) {
+ return;
+ }
+ if (opSignature != null && !shouldCharge(periodStart, opSignature)) {
+ return; // identical inputs seen within the workflow window — chaining, already billed
+ }
+ incrementOrInsert(periodStart, category.name(), units);
+ }
+
+ /**
+ * True when this input set should be charged: unseen this period, or last seen outside the
+ * workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and
+ * slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge.
+ */
+ private boolean shouldCharge(LocalDateTime periodStart, String opSignature) {
+ LocalDateTime now = LocalDateTime.now();
+ MeteredInputSignature seen =
+ signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null);
+ if (seen == null) {
+ try {
+ signatureRepo.saveAndFlush(
+ new MeteredInputSignature(periodStart, opSignature, now));
+ return true; // first sighting this period
+ } catch (DataIntegrityViolationException raced) {
+ return false; // a concurrent op just claimed it — within window → chaining
+ } catch (RuntimeException e) {
+ log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage());
+ return true;
+ }
+ }
+ LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now;
+ boolean withinWindow = last.isAfter(now.minus(workflowWindow));
+ try {
+ seen.touch(now);
+ signatureRepo.save(seen);
+ } catch (RuntimeException e) {
+ log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage());
+ }
+ return !withinWindow;
+ }
+
+ private void incrementOrInsert(LocalDateTime periodStart, String category, long units) {
+ LocalDateTime now = LocalDateTime.now();
+ try {
+ if (repo.increment(periodStart, category, units, now) > 0) {
+ return;
+ }
+ try {
+ repo.saveAndFlush(new UsageCounter(periodStart, category, units, now));
+ } catch (DataIntegrityViolationException raceLostInsert) {
+ // A concurrent request inserted the row first — increment the now-existing row.
+ repo.increment(periodStart, category, units, now);
+ }
+ } catch (RuntimeException e) {
+ // Metering must never break the request it rode in on; a lost accrual self-heals on the
+ // next increment and the daily sync reports the cumulative total either way.
+ log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage());
+ }
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java
new file mode 100644
index 0000000000..4c4ce2377c
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java
@@ -0,0 +1,189 @@
+package stirling.software.proprietary.accountlink;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Profile;
+import org.springframework.scheduling.annotation.SchedulingConfigurer;
+import org.springframework.scheduling.config.FixedDelayTask;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.proprietary.billing.BillingCategory;
+
+/**
+ * Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
+ * usage to SaaS, which bills the delta against its own last-seen totals.
+ *
+ *
Resilience: the sync seq is persisted before the report so it never regresses across
+ * restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
+ * usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All
+ * periods with unsynced usage are reported so nothing is stranded when the period rolls over
+ * between syncs.
+ */
+@Slf4j
+@Service
+@Profile("!saas")
+@ConditionalOnProperty(
+ name = "stirling.billing.account-link.metering.enabled",
+ havingValue = "true")
+public class UsageSyncService implements SchedulingConfigurer {
+
+ // First run waits out startup churn; then every interval.
+ private static final Duration INITIAL_DELAY = Duration.ofMinutes(5);
+
+ private final UsageCounterRepository counters;
+ private final AccountLinkSyncStateRepository syncState;
+ private final DeviceCredentialStore credentialStore;
+ private final AccountLinkClient client;
+ private final EntitlementCache entitlementCache;
+ private final AccountLinkProperties properties;
+
+ public UsageSyncService(
+ UsageCounterRepository counters,
+ AccountLinkSyncStateRepository syncState,
+ DeviceCredentialStore credentialStore,
+ AccountLinkClient client,
+ EntitlementCache entitlementCache,
+ AccountLinkProperties properties) {
+ this.counters = counters;
+ this.syncState = syncState;
+ this.credentialStore = credentialStore;
+ this.client = client;
+ this.entitlementCache = entitlementCache;
+ this.properties = properties;
+ }
+
+ /**
+ * Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in
+ * code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather
+ * than only on a flags-on run.
+ */
+ @Override
+ public void configureTasks(ScheduledTaskRegistrar registrar) {
+ Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours());
+ registrar.addFixedDelayTask(
+ new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY));
+ }
+
+ public void scheduledSync() {
+ try {
+ syncNow();
+ } catch (RuntimeException e) {
+ log.debug("Scheduled usage sync failed", e);
+ }
+ }
+
+ /**
+ * Reports every period with unsynced usage and refreshes the cached entitlement from the reply.
+ * Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when
+ * unlinked or when nothing is pending.
+ */
+ public void syncNow() {
+ Optional cred = credentialStore.get();
+ if (cred.isEmpty()) {
+ return; // not linked
+ }
+ List periods = counters.findPeriodsWithUnsyncedUsage();
+ if (periods.isEmpty()) {
+ // Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement
+ // change (e.g. the admin just subscribed) that otherwise wouldn't surface until the
+ // cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now.
+ entitlementCache.invalidate();
+ entitlementCache.current();
+ return;
+ }
+ InstanceEntitlement latest = null;
+ try {
+ for (LocalDateTime period : periods) {
+ InstanceEntitlement fresh = syncPeriod(cred.get(), period);
+ if (fresh != null) {
+ latest = fresh;
+ }
+ }
+ } catch (AccountLinkClient.RevokedException e) {
+ // Authoritative deny — stop reporting; the entitlement cache blocks billable work on
+ // its
+ // own next refresh, so we don't synthesise the blocked state here.
+ log.info(
+ "Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next"
+ + " refresh",
+ e.status());
+ return;
+ }
+ // Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch.
+ entitlementCache.accept(latest);
+ }
+
+ /** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */
+ private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) {
+ EnumMap cumulative = new EnumMap<>(BillingCategory.class);
+ for (UsageCounter c : counters.findByPeriodStart(period)) {
+ BillingCategory cat = c.billingCategory();
+ if (cat != null && cat != BillingCategory.BYPASSED) {
+ cumulative.merge(cat, c.getCumulativeUnits(), Long::sum);
+ }
+ }
+ AccountLinkSyncState state = loadState();
+ long seq = reserveNextSeq(state);
+ InstanceEntitlement fresh =
+ client.reportUsage(
+ cred.getDeviceId(),
+ cred.getDeviceSecret(),
+ seq,
+ period,
+ cumulative.getOrDefault(BillingCategory.API, 0L),
+ cumulative.getOrDefault(BillingCategory.AI, 0L),
+ cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L));
+ if (fresh == null) {
+ // Transport/server failure: leave the synced markers untouched. The burned seq is
+ // harmless (seqs need only be monotonic) and the delta bills on the next successful
+ // sync.
+ return null;
+ }
+ recordSuccess(period, cumulative, state);
+ return fresh;
+ }
+
+ /** Reserves and persists the next strictly-increasing sequence before the report goes out. */
+ private long reserveNextSeq(AccountLinkSyncState state) {
+ long next = state.getLastSyncSeq() + 1;
+ state.setLastSyncSeq(next);
+ syncState.save(state);
+ return next;
+ }
+
+ /**
+ * Advances the per-category synced markers to the reported totals + stamps the success time.
+ */
+ private void recordSuccess(
+ LocalDateTime period,
+ EnumMap cumulative,
+ AccountLinkSyncState state) {
+ cumulative.forEach(
+ (category, units) -> {
+ if (units > 0) {
+ counters.markSynced(period, category.name(), units);
+ }
+ });
+ state.setLastSuccessAt(LocalDateTime.now());
+ syncState.save(state);
+ }
+
+ private AccountLinkSyncState loadState() {
+ return syncState
+ .findById(AccountLinkSyncState.SINGLETON_ID)
+ .orElseGet(
+ () -> {
+ AccountLinkSyncState s = new AccountLinkSyncState();
+ s.setId(AccountLinkSyncState.SINGLETON_ID);
+ return s;
+ });
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java
new file mode 100644
index 0000000000..51ca47ad64
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java
@@ -0,0 +1,22 @@
+package stirling.software.proprietary.billing;
+
+/**
+ * The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so
+ * category is metadata only and never affects price.
+ *
+ *
Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link
+ * BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
+ * billed.
+ *
+ *
Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted
+ * instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} /
+ * {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the
+ * same names. (We deliberately do not share one enum across the modules: that would drag the SaaS
+ * billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.)
+ */
+public enum BillingCategory {
+ BYPASSED,
+ API,
+ AI,
+ AUTOMATION
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java
new file mode 100644
index 0000000000..95b3abb99c
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java
@@ -0,0 +1,29 @@
+package stirling.software.proprietary.billing;
+
+/**
+ * Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a
+ * linked self-hosted instance classify identically. Each backend resolves the three signals from
+ * its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature}
+ * annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this
+ * applies the order {@code AUTOMATION → AI → API → BYPASSED}.
+ *
+ *
An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the
+ * automation header dominates), while a direct call to it bills as {@code AI}.
+ */
+public final class BillingCategoryClassifier {
+
+ private BillingCategoryClassifier() {}
+
+ public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) {
+ if (automation) {
+ return BillingCategory.AUTOMATION;
+ }
+ if (ai) {
+ return BillingCategory.AI;
+ }
+ if (apiKey) {
+ return BillingCategory.API;
+ }
+ return BillingCategory.BYPASSED;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java
new file mode 100644
index 0000000000..232dd499dc
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java
@@ -0,0 +1,68 @@
+package stirling.software.proprietary.billing;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/**
+ * SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
+ * meter (combined-billing "Mode A"), so both derive an identical signature for the same
+ * bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
+ * of file size), hardware-accelerated by the JVM where available.
+ *
+ *
Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
+ * build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
+ */
+public final class ContentHasher {
+
+ private static final String ALGORITHM = "SHA-256";
+ private static final int BUFFER_SIZE = 64 * 1024;
+
+ private ContentHasher() {}
+
+ /** Lower-case hex SHA-256 of the file's bytes. */
+ public static String sha256(Path file) throws IOException {
+ MessageDigest digest = newDigest();
+ try (InputStream raw = Files.newInputStream(file);
+ DigestInputStream in = new DigestInputStream(raw, digest)) {
+ byte[] buf = new byte[BUFFER_SIZE];
+ while (in.read(buf) != -1) {
+ // drain through the digest; we only want the side effect
+ }
+ }
+ return HexFormat.of().formatHex(digest.digest());
+ }
+
+ /** Lower-case hex SHA-256 of the given bytes (e.g. to combine per-file hashes into one key). */
+ public static String sha256(byte[] bytes) {
+ return HexFormat.of().formatHex(newDigest().digest(bytes));
+ }
+
+ /**
+ * A fresh SHA-256 digest, for callers that stream bytes through a {@link
+ * java.security.DigestOutputStream} to hash in the same pass that writes the file — avoiding a
+ * second full read just to fingerprint it. Pair with {@link #toHex(byte[])}.
+ */
+ public static MessageDigest newSha256() {
+ return newDigest();
+ }
+
+ /** Lower-case hex of a completed digest — the same format {@link #sha256(Path)} produces. */
+ public static String toHex(byte[] digest) {
+ return HexFormat.of().formatHex(digest);
+ }
+
+ private static MessageDigest newDigest() {
+ try {
+ return MessageDigest.getInstance(ALGORITHM);
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is mandated by every JDK; unreachable in practice.
+ throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
+ }
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java
new file mode 100644
index 0000000000..2cc22daf41
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java
@@ -0,0 +1,76 @@
+package stirling.software.proprietary.billing;
+
+import java.util.List;
+
+/**
+ * Pure doc-unit math shared by the SaaS billing engine and a linked self-hosted instance, so both
+ * cost an operation identically. No Spring, no IO: callers supply page/byte facts (read however
+ * their backend reads them — e.g. jpdfium for PDFs) plus a {@link UnitCalcPolicy}.
+ *
+ *
Raw units for one file = the larger of {@code ceil(pages / docPagesPerUnit)} and {@code
+ * ceil(bytes / docBytesPerUnit)} (non-PDF inputs pass {@code pages = 0}, so only the bytes axis
+ * contributes). A single file is clamped to {@code [1, fileUnitCap]}; a multi-file group is the
+ * raw per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not
+ * per-file-clamped, units so the group cap can actually bind).
+ *
+ *
{@link UnitCalcPolicy#minChargeUnits()} is applied by the charge layer, not here; this
+ * enforces only an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on
+ * "non-empty input → at least 1 unit". Extracted verbatim from the SaaS {@code
+ * DefaultDocumentClassifier} to preserve behaviour.
+ */
+public final class DocumentUnitCalculator {
+
+ /** Floor for non-empty input. Distinct from {@link UnitCalcPolicy#minChargeUnits()}. */
+ public static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
+
+ private DocumentUnitCalculator() {}
+
+ /** One file's page count (0 for non-PDF / unreadable) and byte size. */
+ public record FileSize(int pages, long bytes) {}
+
+ /** Raw (unclamped) units for one file. */
+ public static long rawUnits(int pages, long bytes, UnitCalcPolicy policy) {
+ long pageUnits = pages > 0 ? ceilDiv(pages, policy.docPagesPerUnit()) : 0L;
+ long byteUnits = ceilDiv(bytes, policy.docBytesPerUnit());
+ return Math.max(pageUnits, byteUnits);
+ }
+
+ /** Units for a single file, clamped to {@code [1, fileUnitCap]}. */
+ public static int unitsForFile(int pages, long bytes, UnitCalcPolicy policy) {
+ long raw = rawUnits(pages, bytes, policy);
+ // toIntExact: fail loud on overflow rather than silently wrapping a billing number.
+ return Math.toIntExact(
+ Math.max(MIN_UNITS_PER_NONEMPTY_FILE, Math.min(policy.fileUnitCap(), raw)));
+ }
+
+ /**
+ * Units for a multi-file group: raw per-file sum clamped to {@code [1, fileUnitCap * count]}.
+ */
+ public static int unitsForGroup(List files, UnitCalcPolicy policy) {
+ if (files.isEmpty()) {
+ throw new IllegalArgumentException("files must not be empty");
+ }
+ long rawSum = 0;
+ for (FileSize f : files) {
+ rawSum = saturatedAdd(rawSum, rawUnits(f.pages(), f.bytes(), policy));
+ }
+ long groupCap = (long) policy.fileUnitCap() * files.size();
+ return Math.toIntExact(
+ Math.max((long) MIN_UNITS_PER_NONEMPTY_FILE, Math.min(groupCap, rawSum)));
+ }
+
+ private static long ceilDiv(long numerator, long divisor) {
+ if (numerator <= 0) {
+ return 0;
+ }
+ return (numerator + divisor - 1) / divisor;
+ }
+
+ private static long saturatedAdd(long a, long b) {
+ try {
+ return Math.addExact(a, b);
+ } catch (ArithmeticException e) {
+ return Long.MAX_VALUE;
+ }
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java
new file mode 100644
index 0000000000..b7d773d465
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java
@@ -0,0 +1,30 @@
+package stirling.software.proprietary.billing;
+
+/**
+ * The four billing knobs the doc-unit math needs, split out of the SaaS {@code PricingPolicy} JPA
+ * entity so the calculation ({@link DocumentUnitCalculator}) can live in {@code :proprietary} and
+ * be shared by the SaaS billing engine and a linked self-hosted instance — both then cost an
+ * operation identically.
+ *
+ *
The SaaS engine builds one from its persisted {@code PricingPolicy}; a linked instance
+ * receives these values in the daily entitlement sync. {@code minChargeUnits} is carried here for
+ * the charge layer; {@link DocumentUnitCalculator} itself does not apply it (see its docs).
+ */
+public record UnitCalcPolicy(
+ int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) {
+
+ public UnitCalcPolicy {
+ if (docPagesPerUnit <= 0) {
+ throw new IllegalArgumentException("docPagesPerUnit must be > 0");
+ }
+ if (docBytesPerUnit <= 0) {
+ throw new IllegalArgumentException("docBytesPerUnit must be > 0");
+ }
+ if (minChargeUnits < 1) {
+ throw new IllegalArgumentException("minChargeUnits must be >= 1");
+ }
+ if (fileUnitCap < 1) {
+ throw new IllegalArgumentException("fileUnitCap must be >= 1");
+ }
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java
index 7d954a4398..969111e9f5 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java
@@ -12,6 +12,7 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -189,4 +190,73 @@ void revokeSelfReturnsFalseWhenUnreachable() throws Exception {
.thenThrow(new ConnectException("refused"));
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception {
+ HttpResponse resp =
+ response(
+ 200,
+ "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}");
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class);
+ when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
+ .thenReturn(resp);
+
+ InstanceEntitlement e =
+ client.reportUsage(
+ "dev-1", "sec-1", 7L, LocalDateTime.of(2026, 6, 1, 0, 0), 12, 4, 8);
+
+ assertNotNull(e);
+ assertEquals(42, e.periodSpendUnits());
+ assertEquals(EntitlementState.OK, e.state());
+
+ HttpRequest sent = captor.getValue();
+ assertEquals("https://saas.example.com/api/v1/instance/sync", sent.uri().toString());
+ assertEquals("POST", sent.method());
+ assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
+ assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void reportUsageThrowsRevokedOnDeny() throws Exception {
+ for (int status : new int[] {401, 403}) {
+ HttpResponse resp = response(status, "{}");
+ when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
+ AccountLinkClient.RevokedException ex =
+ assertThrows(
+ AccountLinkClient.RevokedException.class,
+ () ->
+ client.reportUsage(
+ "dev-1",
+ "sec-1",
+ 1L,
+ LocalDateTime.of(2026, 6, 1, 0, 0),
+ 1,
+ 0,
+ 0));
+ assertEquals(status, ex.status());
+ }
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void reportUsageReturnsNullWhenUnreachable() throws Exception {
+ when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
+ .thenThrow(new ConnectException("refused"));
+ // Null = don't advance synced markers; the usage retries on the next sync.
+ assertNull(
+ client.reportUsage(
+ "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void reportUsageReturnsNullOnServerError() throws Exception {
+ HttpResponse resp = response(503, "{}");
+ when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
+ assertNull(
+ client.reportUsage(
+ "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0));
+ }
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java
index 6e4a816bb0..41f544de7e 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java
@@ -2,12 +2,15 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -21,12 +24,18 @@
class AccountLinkControllerTest {
private AccountLinkService service;
+ private UsageSyncService syncService;
+ private ObjectProvider syncProvider;
private AccountLinkController controller;
@BeforeEach
+ @SuppressWarnings("unchecked")
void setUp() {
service = mock(AccountLinkService.class);
- controller = new AccountLinkController(service);
+ syncService = mock(UsageSyncService.class);
+ syncProvider = mock(ObjectProvider.class);
+ controller =
+ new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
}
@Test
@@ -65,4 +74,24 @@ void link_transportFailure_maps502() throws Exception {
ResponseEntity> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
+
+ @Test
+ void syncNow_triggersSyncWhenMeteringOn() {
+ when(syncProvider.getIfAvailable()).thenReturn(syncService);
+
+ ResponseEntity resp = controller.syncNow();
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
+ verify(syncService).syncNow();
+ }
+
+ @Test
+ void syncNow_returns409WhenMeteringOff() {
+ when(syncProvider.getIfAvailable()).thenReturn(null); // metering disabled → bean absent
+
+ ResponseEntity resp = controller.syncNow();
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
+ verify(syncService, never()).syncNow();
+ }
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java
index 275026794a..0b43069ee9 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java
@@ -1,49 +1,78 @@
package stirling.software.proprietary.accountlink;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.common.service.InternalApiClient;
+import stirling.software.proprietary.billing.BillingCategory;
class BillableOperationClassifierTest {
+ private static MockHttpServletRequest req(String uri) {
+ return new MockHttpServletRequest("POST", uri);
+ }
+
@Test
- void aiPathIsBillable() {
- MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
- assertTrue(BillableOperationClassifier.isBillable(req));
+ void aiPathIsAi() {
+ assertEquals(
+ BillingCategory.AI,
+ BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false));
}
@Test
- void automationHeaderIsBillable() {
- MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
+ void automationHeaderIsAutomation() {
+ MockHttpServletRequest req = req("/api/v1/general/merge");
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1");
- assertTrue(BillableOperationClassifier.isBillable(req));
+ assertEquals(
+ BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false));
+ }
+
+ @Test
+ void apiKeyToolCallIsApi() {
+ assertEquals(
+ BillingCategory.API,
+ BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true));
+ }
+
+ @Test
+ void plainManualToolIsBypassed() {
+ assertEquals(
+ BillingCategory.BYPASSED,
+ BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false));
+ }
+
+ @Test
+ void automationDominatesAiAndApiKey() {
+ // An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION.
+ MockHttpServletRequest req = req("/api/v1/ai/tools/foo");
+ req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
+ assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true));
}
@Test
- void plainManualToolIsFree() {
- MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
- assertFalse(BillableOperationClassifier.isBillable(req));
+ void aiDominatesApiKey() {
+ // A direct API-key call to an AI tool bills as AI, not API.
+ assertEquals(
+ BillingCategory.AI,
+ BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true));
}
@Test
- void aiSegmentNotAtPathStartIsFree() {
- // Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a
- // proxy prefix) must NOT classify a manual tool as billable.
- MockHttpServletRequest req =
- new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo");
- assertFalse(BillableOperationClassifier.isBillable(req));
+ void aiSegmentNotAtPathStartIsBypassed() {
+ // Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix)
+ // must NOT classify a manual tool as AI.
+ assertEquals(
+ BillingCategory.BYPASSED,
+ BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false));
}
@Test
- void aiPathUnderContextPathIsBillable() {
- // A real context-path deployment still classifies: //api/v1/ai/** is billable.
- MockHttpServletRequest req =
- new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
+ void aiPathUnderContextPathIsAi() {
+ // A real context-path deployment still classifies: //api/v1/ai/** is AI.
+ MockHttpServletRequest req = req("/stirling/api/v1/ai/tools/foo");
req.setContextPath("/stirling");
- assertTrue(BillableOperationClassifier.isBillable(req));
+ assertEquals(BillingCategory.AI, BillableOperationClassifier.categorize(req, false));
}
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java
index 0c250fd3e9..1838034a9a 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java
@@ -3,20 +3,32 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.accountlink.GateDecision.Reason;
/**
- * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and
- * over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is
- * needed.
+ * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired,
+ * linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no
+ * Spring; the grace-window reference computation is exercised through {@link
+ * InstanceEntitlementGate#evaluate} with mocked collaborators.
*/
+@ExtendWith(MockitoExtension.class)
class InstanceEntitlementGateTest {
+ @Mock private DeviceCredentialStore credentialStore;
+ @Mock private EntitlementCache entitlementCache;
+ @Mock private AccountLinkSyncStateRepository syncStateRepository;
+ @Mock private LocalUsageService localUsageService;
+
private static InstanceEntitlement free() {
return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK);
}
@@ -35,35 +47,67 @@ private static InstanceEntitlement subscribedOverCap() {
@Test
void flagOff_allowsEverything_evenBillableUnlinked() {
- GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty());
+ GateDecision d =
+ InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.FLAG_OFF, d.reason());
}
@Test
void manualTool_alwaysFree_evenUnlinked() {
- GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty());
+ GateDecision d =
+ InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.MANUAL_FREE, d.reason());
}
@Test
void billable_notLinked_blocksWithLinkSignal() {
- GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty());
+ GateDecision d =
+ InstanceEntitlementGate.decide(true, true, false, Optional.empty(), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.NOT_LINKED, d.reason());
}
@Test
- void billable_linked_entitlementUnreachable_failsOpen() {
- GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty());
+ void billable_linked_entitlementUnreachable_withinGrace_failsOpen() {
+ GateDecision d =
+ InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.FAIL_OPEN, d.reason());
}
+ @Test
+ void billable_linked_entitlementUnreachable_graceExpired_blocks() {
+ GateDecision d =
+ InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L);
+ assertFalse(d.allowed());
+ assertEquals(Reason.GRACE_EXPIRED, d.reason());
+ }
+
@Test
void billable_linked_freePoolAvailable_allows() {
- GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free()));
+ GateDecision d =
+ InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 0L);
+ assertTrue(d.allowed());
+ assertEquals(Reason.ENTITLED, d.reason());
+ }
+
+ @Test
+ void billable_linked_unsubscribed_pendingLocalUsageDepletesGrant_blocks() {
+ // free() has 100 free units left per the last sync; 100 accrued locally since would exhaust
+ // it once charged, so the gate stops here in real time rather than waiting for the sync.
+ GateDecision d =
+ InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 100L);
+ assertFalse(d.allowed());
+ assertEquals(Reason.OVER_LIMIT, d.reason());
+ }
+
+ @Test
+ void billable_linked_unsubscribed_pendingLocalUsageLeavesRoom_allows() {
+ // 99 pending against 100 remaining → one unit of grant still projected free → allow.
+ GateDecision d =
+ InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 99L);
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@@ -72,7 +116,7 @@ void billable_linked_freePoolAvailable_allows() {
void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
GateDecision d =
InstanceEntitlementGate.decide(
- true, true, true, Optional.of(exhaustedUnsubscribed()));
+ true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@@ -81,7 +125,7 @@ void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
void billable_linked_subscribedWithinCap_allows() {
GateDecision d =
InstanceEntitlementGate.decide(
- true, true, true, Optional.of(subscribedWithinCap()));
+ true, true, true, Optional.of(subscribedWithinCap()), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@@ -89,18 +133,67 @@ void billable_linked_subscribedWithinCap_allows() {
@Test
void billable_linked_subscribedOverCap_blocks() {
GateDecision d =
- InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap()));
+ InstanceEntitlementGate.decide(
+ true, true, true, Optional.of(subscribedOverCap()), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
+ @Test
+ void billable_linked_subscribedCapped_pendingLocalUsageWouldExceedCap_blocks() {
+ // Within cap per the last sync (spend 10 / cap 100), but 95 accrued locally since would
+ // push
+ // projected spend to 105 → the gate stops now, not after the next sync reconciles.
+ GateDecision d =
+ InstanceEntitlementGate.decide(
+ true, true, true, Optional.of(subscribedWithinCap()), false, 95L);
+ assertFalse(d.allowed());
+ assertEquals(Reason.OVER_LIMIT, d.reason());
+ }
+
+ @Test
+ void billable_linked_subscribedCapped_pendingLeavesCapRoom_allows() {
+ // 10 synced + 80 pending = 90 < 100 cap → still room.
+ GateDecision d =
+ InstanceEntitlementGate.decide(
+ true, true, true, Optional.of(subscribedWithinCap()), false, 80L);
+ assertTrue(d.allowed());
+ assertEquals(Reason.ENTITLED, d.reason());
+ }
+
+ @Test
+ void billable_linked_subscribedCapped_freeGrantAbsorbsPending_allows() {
+ // 50 free units remain, so 40 pending is entirely free → 0 projected paid < 100 cap →
+ // allow.
+ InstanceEntitlement subscribedWithGrant =
+ new InstanceEntitlement(true, 50, 0, 100L, EntitlementState.OK);
+ GateDecision d =
+ InstanceEntitlementGate.decide(
+ true, true, true, Optional.of(subscribedWithGrant), false, 40L);
+ assertTrue(d.allowed());
+ assertEquals(Reason.ENTITLED, d.reason());
+ }
+
+ @Test
+ void billable_linked_subscribedUncapped_pendingIgnored_allows() {
+ // No cap → local pending has no ceiling to hit → always allowed.
+ InstanceEntitlement uncapped =
+ new InstanceEntitlement(true, 0, 999, null, EntitlementState.OK);
+ GateDecision d =
+ InstanceEntitlementGate.decide(
+ true, true, true, Optional.of(uncapped), false, 500L);
+ assertTrue(d.allowed());
+ assertEquals(Reason.ENTITLED, d.reason());
+ }
+
@Test
void billable_linked_revoked_blocksWithRevokedSignal() {
// Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED —
// blocks distinctly from over-limit, even though the snapshot is "present".
InstanceEntitlement revoked =
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
- GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked));
+ GateDecision d =
+ InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.REVOKED, d.reason());
}
@@ -110,7 +203,98 @@ void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() {
// Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive.
InstanceEntitlement conflicting =
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT);
- GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting));
+ GateDecision d =
+ InstanceEntitlementGate.decide(
+ true, true, true, Optional.of(conflicting), false, 0L);
+ assertFalse(d.allowed());
+ assertEquals(Reason.OVER_LIMIT, d.reason());
+ }
+
+ // --- grace window (evaluate()) ---------------------------------------------------------------
+
+ private InstanceEntitlementGate gate(AccountLinkProperties props) {
+ return new InstanceEntitlementGate(
+ props, credentialStore, entitlementCache, syncStateRepository, localUsageService);
+ }
+
+ private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) {
+ AccountLinkProperties p = new AccountLinkProperties();
+ p.setEnabled(true);
+ p.getMetering().setEnabled(meteringEnabled);
+ p.getMetering().setGraceDays(graceDays);
+ return p;
+ }
+
+ @Test
+ void evaluate_meteringOff_unreachable_failsOpen_neverGraceBlocks() {
+ when(credentialStore.isLinked()).thenReturn(true);
+ when(entitlementCache.current()).thenReturn(Optional.empty());
+
+ GateDecision d = gate(props(false, 3)).evaluate(true);
+
+ // Metering off → grace never applies, even if a sync is ancient.
+ assertTrue(d.allowed());
+ assertEquals(Reason.FAIL_OPEN, d.reason());
+ }
+
+ @Test
+ void evaluate_neverSynced_pastGraceSinceLink_blocks() {
+ when(credentialStore.isLinked()).thenReturn(true);
+ when(entitlementCache.current()).thenReturn(Optional.empty());
+ when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID))
+ .thenReturn(Optional.empty());
+ DeviceCredential cred = new DeviceCredential();
+ cred.setLinkedAt(LocalDateTime.now().minusDays(5));
+ when(credentialStore.get()).thenReturn(Optional.of(cred));
+
+ GateDecision d = gate(props(true, 3)).evaluate(true);
+
+ assertFalse(d.allowed());
+ assertEquals(Reason.GRACE_EXPIRED, d.reason());
+ }
+
+ @Test
+ void evaluate_recentSync_withinGrace_failsOpen() {
+ when(credentialStore.isLinked()).thenReturn(true);
+ when(entitlementCache.current()).thenReturn(Optional.empty());
+ AccountLinkSyncState state = new AccountLinkSyncState();
+ state.setLastSuccessAt(LocalDateTime.now().minusDays(1));
+ when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID))
+ .thenReturn(Optional.of(state));
+
+ GateDecision d = gate(props(true, 3)).evaluate(true);
+
+ assertTrue(d.allowed());
+ assertEquals(Reason.FAIL_OPEN, d.reason());
+ }
+
+ @Test
+ void evaluate_unsubscribed_localUsageWouldExceedGrant_blocksInRealTime() {
+ // 100 free units remaining per the last sync, but 100 already accrued locally since — the
+ // gate subtracts the pending delta and blocks now, not after the next sync reconciles.
+ when(credentialStore.isLinked()).thenReturn(true);
+ when(entitlementCache.current()).thenReturn(Optional.of(free()));
+ when(localUsageService.currentPeriodUnsynced())
+ .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 100, 0, 0, 100));
+
+ GateDecision d = gate(props(true, 3)).evaluate(true);
+
+ assertFalse(d.allowed());
+ assertEquals(Reason.OVER_LIMIT, d.reason());
+ }
+
+ @Test
+ void evaluate_subscribedCapped_localUsageWouldExceedCap_blocksInRealTime() {
+ // Subscribed within cap per the last sync (spend 10 / cap 100), but 90 accrued locally
+ // since — evaluate() now depletes the cap by pending usage for capped subscriptions too, so
+ // the gate stops now instead of overshooting the cap until the next sync.
+ when(credentialStore.isLinked()).thenReturn(true);
+ when(entitlementCache.current()).thenReturn(Optional.of(subscribedWithinCap()));
+ when(localUsageService.currentPeriodUnsynced())
+ .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 0, 90, 0, 90));
+
+ GateDecision d = gate(props(true, 3)).evaluate(true);
+
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java
index f764f5e836..06b99e0eb5 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java
@@ -19,6 +19,7 @@ class InstanceEntitlementGateWiringTest {
private AccountLinkProperties properties;
private DeviceCredentialStore store;
private EntitlementCache cache;
+ private LocalUsageService localUsage;
private InstanceEntitlementGate gate;
@BeforeEach
@@ -27,7 +28,14 @@ void setUp() {
properties.setEnabled(true);
store = mock(DeviceCredentialStore.class);
cache = mock(EntitlementCache.class);
- gate = new InstanceEntitlementGate(properties, store, cache);
+ localUsage = mock(LocalUsageService.class);
+ gate =
+ new InstanceEntitlementGate(
+ properties,
+ store,
+ cache,
+ mock(AccountLinkSyncStateRepository.class),
+ localUsage);
}
@Test
@@ -55,6 +63,10 @@ void billableLinkedConsultsCache() {
.thenReturn(
Optional.of(
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK)));
+ // Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time;
+ // nothing pending here, so the 5 free units still allow the request.
+ when(localUsage.currentPeriodUnsynced())
+ .thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0));
GateDecision d = gate.evaluate(true);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.ENTITLED, d.reason());
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java
index 709e3c0866..13a2606105 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java
@@ -3,24 +3,55 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.ArgumentMatchers.notNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.mock.web.MockMultipartHttpServletRequest;
+
+import stirling.software.common.util.TempFile;
+import stirling.software.common.util.TempFileManager;
+import stirling.software.proprietary.billing.BillingCategory;
+import stirling.software.proprietary.billing.UnitCalcPolicy;
@ExtendWith(MockitoExtension.class)
class InstanceEntitlementInterceptorTest {
@Mock private InstanceEntitlementGate gate;
+ @Mock private EntitlementCache entitlementCache;
+ @Mock private ObjectProvider meterProvider;
+ @Mock private TempFileManager tempFileManager;
+
+ private InstanceEntitlementInterceptor interceptor() {
+ return new InstanceEntitlementInterceptor(
+ gate, entitlementCache, meterProvider, tempFileManager);
+ }
private boolean preHandle(MockHttpServletResponse response) throws Exception {
- return new InstanceEntitlementInterceptor(gate)
+ return interceptor()
.preHandle(
new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object());
}
@@ -58,4 +89,85 @@ void failsOpenWhenGateThrows() throws Exception {
assertTrue(preHandle(response));
assertEquals(200, response.getStatus());
}
+
+ @Test
+ void metersSuccessfulBillableOp() throws Exception {
+ when(gate.evaluate(anyBoolean()))
+ .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
+ UsageMeterService meter = mock(UsageMeterService.class);
+ when(meterProvider.getIfAvailable()).thenReturn(meter);
+ UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000);
+ LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
+ when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period)));
+
+ InstanceEntitlementInterceptor interceptor = interceptor();
+ MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x");
+ MockHttpServletResponse resp = new MockHttpServletResponse();
+ interceptor.preHandle(req, resp, new Object()); // stashes AI category
+ interceptor.afterCompletion(req, resp, new Object(), null);
+
+ // No uploaded files → bytes axis → the 1-unit floor; no input identity → null signature.
+ verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L), isNull());
+ }
+
+ @Test
+ void metersPdfByPageCountNotJustBytes(@TempDir Path tmp) throws Exception {
+ when(gate.evaluate(anyBoolean()))
+ .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
+ UsageMeterService meter = mock(UsageMeterService.class);
+ when(meterProvider.getIfAvailable()).thenReturn(meter);
+ // docPagesPerUnit=1, docBytesPerUnit=1MB → a tiny 5-page PDF costs 5 on the page axis but
+ // only 1 on the byte axis: page-counting (via jpdfium) is what makes this bill correctly.
+ UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000);
+ LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
+ when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period)));
+ // Materialise to a real path under @TempDir; the interceptor writes the upload there and
+ // jpdfium + the hasher read it back.
+ TempFile temp = mock(TempFile.class);
+ when(temp.getPath()).thenReturn(tmp.resolve("input.bin"));
+ when(tempFileManager.createManagedTempFile(any())).thenReturn(temp);
+
+ InstanceEntitlementInterceptor interceptor = interceptor();
+ MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest();
+ req.setRequestURI("/api/v1/ai/x");
+ req.addFile(new MockMultipartFile("file", "doc.pdf", "application/pdf", fivePagePdf()));
+ MockHttpServletResponse resp = new MockHttpServletResponse();
+ interceptor.preHandle(req, resp, new Object());
+ interceptor.afterCompletion(req, resp, new Object(), null);
+
+ // 5 pages + a non-null input-set signature (file ops carry a dedup key).
+ verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L), notNull());
+ }
+
+ @Test
+ void doesNotMeterWhenMeteringSwitchOff() throws Exception {
+ when(gate.evaluate(anyBoolean()))
+ .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
+ when(meterProvider.getIfAvailable()).thenReturn(null); // metering.enabled = false
+
+ InstanceEntitlementInterceptor interceptor = interceptor();
+ MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x");
+ MockHttpServletResponse resp = new MockHttpServletResponse();
+ interceptor.preHandle(req, resp, new Object());
+ interceptor.afterCompletion(req, resp, new Object(), null);
+
+ // Meter absent → no entitlement lookup, no accrual.
+ verifyNoInteractions(entitlementCache);
+ }
+
+ private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) {
+ return new InstanceEntitlement(
+ true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1));
+ }
+
+ private static byte[] fivePagePdf() throws Exception {
+ try (PDDocument doc = new PDDocument();
+ ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ for (int i = 0; i < 5; i++) {
+ doc.addPage(new PDPage());
+ }
+ doc.save(out);
+ return out.toByteArray();
+ }
+ }
}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java
new file mode 100644
index 0000000000..4605348666
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java
@@ -0,0 +1,75 @@
+package stirling.software.proprietary.accountlink;
+
+import static org.assertj.core.api.Assertions.assertThat;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class LocalUsageServiceTest {
+
+ @Mock private UsageCounterRepository counters;
+ @Mock private EntitlementCache entitlementCache;
+
+ private LocalUsageService service;
+ private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
+
+ @BeforeEach
+ void setUp() {
+ service = new LocalUsageService(counters, entitlementCache);
+ }
+
+ private static UsageCounter counter(
+ LocalDateTime period, String category, long cumulative, long synced) {
+ return new UsageCounter(period, category, cumulative, synced, LocalDateTime.now());
+ }
+
+ private static InstanceEntitlement entitledFor(LocalDateTime periodStart) {
+ return new InstanceEntitlement(
+ true,
+ 0,
+ 0,
+ null,
+ EntitlementState.OK,
+ null,
+ periodStart,
+ periodStart.plusMonths(1));
+ }
+
+ @Test
+ void unknownPeriodReturnsZeros() {
+ when(entitlementCache.current()).thenReturn(Optional.empty());
+
+ LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced();
+
+ assertThat(usage.periodStart()).isNull();
+ assertThat(usage.totalUnsyncedUnits()).isZero();
+ }
+
+ @Test
+ void sumsPerCategoryUnsyncedDeltaForCurrentPeriod() {
+ when(entitlementCache.current()).thenReturn(Optional.of(entitledFor(period)));
+ when(counters.findByPeriodStart(period))
+ .thenReturn(
+ List.of(
+ counter(period, "API", 30L, 10L), // 20 unsynced
+ counter(period, "AI", 4L, 4L), // 0 unsynced (all reported)
+ counter(period, "AUTOMATION", 7L, 2L))); // 5 unsynced
+
+ LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced();
+
+ assertThat(usage.periodStart()).isEqualTo(period);
+ assertThat(usage.apiUnsyncedUnits()).isEqualTo(20L);
+ assertThat(usage.aiUnsyncedUnits()).isEqualTo(0L);
+ assertThat(usage.automationUnsyncedUnits()).isEqualTo(5L);
+ assertThat(usage.totalUnsyncedUnits()).isEqualTo(25L);
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java
new file mode 100644
index 0000000000..0c9ad2c62e
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java
@@ -0,0 +1,133 @@
+package stirling.software.proprietary.accountlink;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DataIntegrityViolationException;
+
+import stirling.software.proprietary.billing.BillingCategory;
+
+@ExtendWith(MockitoExtension.class)
+class UsageMeterServiceTest {
+
+ @Mock private UsageCounterRepository repo;
+ @Mock private MeteredInputSignatureRepository signatureRepo;
+
+ private UsageMeterService service;
+ private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
+
+ @BeforeEach
+ void setUp() {
+ service = new UsageMeterService(repo, signatureRepo, new AccountLinkProperties());
+ }
+
+ @Test
+ void incrementsExistingCounter() {
+ when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
+
+ service.accrue(period, BillingCategory.AI, 5, null);
+
+ verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
+ verify(repo, never()).saveAndFlush(any());
+ }
+
+ @Test
+ void insertsWhenNoRowExists() {
+ when(repo.increment(eq(period), eq("API"), eq(3L), any())).thenReturn(0);
+
+ service.accrue(period, BillingCategory.API, 3, null);
+
+ verify(repo).saveAndFlush(any(UsageCounter.class));
+ }
+
+ @Test
+ void retriesIncrementWhenInsertLosesRace() {
+ // First increment misses (no row); insert loses the race to a concurrent thread; the
+ // second increment then succeeds against the row that thread created.
+ when(repo.increment(eq(period), eq("AUTOMATION"), eq(2L), any())).thenReturn(0, 1);
+ when(repo.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("dup"));
+
+ service.accrue(period, BillingCategory.AUTOMATION, 2, null);
+
+ verify(repo, times(2)).increment(eq(period), eq("AUTOMATION"), eq(2L), any());
+ }
+
+ @Test
+ void skipsBypassedNonPositiveAndNullPeriod() {
+ service.accrue(period, BillingCategory.BYPASSED, 5, null);
+ service.accrue(period, BillingCategory.AI, 0, null);
+ service.accrue(null, BillingCategory.AI, 5, null);
+
+ verifyNoInteractions(repo, signatureRepo);
+ }
+
+ @Test
+ void chargesNewSignatureThenAccrues() {
+ when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-new"))
+ .thenReturn(Optional.empty());
+ when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
+
+ service.accrue(period, BillingCategory.AI, 5, "op-sig-new");
+
+ verify(signatureRepo).saveAndFlush(any(MeteredInputSignature.class));
+ verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
+ }
+
+ @Test
+ void skipsConcurrentDuplicateClaim() {
+ // Unseen this period, but a concurrent op wins the insert first → treated as within-window
+ // chaining, not re-charged.
+ when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-race"))
+ .thenReturn(Optional.empty());
+ when(signatureRepo.saveAndFlush(any()))
+ .thenThrow(new DataIntegrityViolationException("dup"));
+
+ service.accrue(period, BillingCategory.AI, 5, "op-sig-race");
+
+ verify(repo, never()).increment(any(), any(), anyLong(), any());
+ verify(repo, never()).saveAndFlush(any());
+ }
+
+ @Test
+ void skipsRepeatWithinWorkflowWindow() {
+ // Same input set seen moments ago → chaining → not re-charged; the window slides.
+ MeteredInputSignature recent =
+ new MeteredInputSignature(period, "op-sig", LocalDateTime.now());
+ when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig"))
+ .thenReturn(Optional.of(recent));
+
+ service.accrue(period, BillingCategory.AI, 5, "op-sig");
+
+ verify(repo, never()).increment(any(), any(), anyLong(), any());
+ verify(signatureRepo).save(recent); // window touched
+ }
+
+ @Test
+ void chargesRepeatOutsideWorkflowWindow() {
+ // Same input set last seen well past the 5-minute window → an independent re-run → charged.
+ MeteredInputSignature stale =
+ new MeteredInputSignature(period, "op-sig", LocalDateTime.now().minusMinutes(10));
+ when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig"))
+ .thenReturn(Optional.of(stale));
+ when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
+
+ service.accrue(period, BillingCategory.AI, 5, "op-sig");
+
+ verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
+ verify(signatureRepo).save(stale); // window touched
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java
new file mode 100644
index 0000000000..1b1ade7e80
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java
@@ -0,0 +1,171 @@
+package stirling.software.proprietary.accountlink;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.time.Duration;
+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.scheduling.config.ScheduledTaskRegistrar;
+
+@ExtendWith(MockitoExtension.class)
+class UsageSyncServiceTest {
+
+ @Mock private UsageCounterRepository counters;
+ @Mock private AccountLinkSyncStateRepository syncState;
+ @Mock private DeviceCredentialStore credentialStore;
+ @Mock private AccountLinkClient client;
+ @Mock private EntitlementCache entitlementCache;
+
+ private UsageSyncService service;
+ private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
+
+ @BeforeEach
+ void setUp() {
+ service =
+ new UsageSyncService(
+ counters,
+ syncState,
+ credentialStore,
+ client,
+ entitlementCache,
+ new AccountLinkProperties());
+ }
+
+ @Test
+ void registersFixedDelayTaskWithConfiguredInterval() {
+ AccountLinkProperties props = new AccountLinkProperties();
+ props.getMetering().setSyncIntervalHours(6);
+ UsageSyncService svc =
+ new UsageSyncService(
+ counters, syncState, credentialStore, client, entitlementCache, props);
+
+ ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar();
+ svc.configureTasks(registrar);
+
+ // Pins the interval binding in CI — the old @Scheduled SpEL only resolved at flags-on boot.
+ assertThat(registrar.getFixedDelayTaskList()).hasSize(1);
+ assertThat(registrar.getFixedDelayTaskList().get(0).getIntervalDuration())
+ .isEqualTo(Duration.ofHours(6));
+ }
+
+ private static DeviceCredential credential() {
+ DeviceCredential c = new DeviceCredential();
+ c.setDeviceId("dev-1");
+ c.setDeviceSecret("sec-1");
+ return c;
+ }
+
+ private static UsageCounter counter(LocalDateTime period, String category, long cumulative) {
+ return new UsageCounter(period, category, cumulative, LocalDateTime.now());
+ }
+
+ private static InstanceEntitlement entitled() {
+ return new InstanceEntitlement(true, 0, 0, null, EntitlementState.OK);
+ }
+
+ @Test
+ void notLinkedSkipsEntirely() {
+ when(credentialStore.get()).thenReturn(Optional.empty());
+
+ service.syncNow();
+
+ verifyNoInteractions(client, entitlementCache);
+ verify(counters, never()).findPeriodsWithUnsyncedUsage();
+ }
+
+ @Test
+ void nothingPendingStillForcesEntitlementRefresh() {
+ when(credentialStore.get()).thenReturn(Optional.of(credential()));
+ when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of());
+
+ service.syncNow();
+
+ // No usage to report, so nothing is sent and no markers advance — but the sync still forces
+ // an entitlement refresh so an out-of-band plan change (e.g. a just-completed subscription)
+ // surfaces on the gate immediately instead of waiting out the entitlement-cache TTL.
+ verifyNoInteractions(client);
+ verify(syncState, never()).save(any());
+ verify(entitlementCache, never()).accept(any());
+ verify(entitlementCache).invalidate();
+ verify(entitlementCache).current();
+ }
+
+ @Test
+ void reportsCumulativePerCategoryAndAdvancesSyncedMarkers() {
+ AccountLinkSyncState state = new AccountLinkSyncState();
+ state.setId(AccountLinkSyncState.SINGLETON_ID);
+ state.setLastSyncSeq(5L);
+ when(credentialStore.get()).thenReturn(Optional.of(credential()));
+ when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
+ when(counters.findByPeriodStart(period))
+ .thenReturn(List.of(counter(period, "API", 12L), counter(period, "AI", 4L)));
+ when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
+ InstanceEntitlement fresh = entitled();
+ when(client.reportUsage(
+ eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L)))
+ .thenReturn(fresh);
+
+ service.syncNow();
+
+ // Seq advanced from 5 → 6 and the report carried the per-category cumulative.
+ verify(client)
+ .reportUsage(eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L));
+ // Only categories with usage are marked; AUTOMATION (0) is skipped.
+ verify(counters).markSynced(period, "API", 12L);
+ verify(counters).markSynced(period, "AI", 4L);
+ verify(counters, never()).markSynced(eq(period), eq("AUTOMATION"), anyLong());
+ // Two saves: the pre-report seq reservation + the post-success timestamp.
+ verify(syncState, times(2)).save(state);
+ verify(entitlementCache).accept(fresh);
+ }
+
+ @Test
+ void transportFailureReservesSeqButLeavesMarkersUntouched() {
+ AccountLinkSyncState state = new AccountLinkSyncState();
+ state.setId(AccountLinkSyncState.SINGLETON_ID);
+ when(credentialStore.get()).thenReturn(Optional.of(credential()));
+ when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
+ when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L)));
+ when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
+ when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong()))
+ .thenReturn(null);
+
+ service.syncNow();
+
+ verify(counters, never()).markSynced(any(), any(), anyLong());
+ verify(syncState, times(1)).save(state); // seq reserved, success not recorded
+ verify(entitlementCache).accept(null); // nothing fresh adopted
+ }
+
+ @Test
+ void revokedAbortsWithoutMarkingOrAdoptingEntitlement() {
+ AccountLinkSyncState state = new AccountLinkSyncState();
+ state.setId(AccountLinkSyncState.SINGLETON_ID);
+ when(credentialStore.get()).thenReturn(Optional.of(credential()));
+ when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
+ when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L)));
+ when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
+ when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong()))
+ .thenThrow(new AccountLinkClient.RevokedException(403));
+
+ service.syncNow();
+
+ verify(counters, never()).markSynced(any(), any(), anyLong());
+ verify(entitlementCache, never()).accept(any());
+ }
+}
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java
new file mode 100644
index 0000000000..1eb6c3360a
--- /dev/null
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java
@@ -0,0 +1,30 @@
+package stirling.software.proprietary.billing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class BillingCategoryClassifierTest {
+
+ @Test
+ void automationWinsOverEverything() {
+ assertEquals(
+ BillingCategory.AUTOMATION, BillingCategoryClassifier.classify(true, true, true));
+ }
+
+ @Test
+ void aiWinsOverApiKey() {
+ assertEquals(BillingCategory.AI, BillingCategoryClassifier.classify(false, true, true));
+ }
+
+ @Test
+ void apiKeyWhenNotAutomationOrAi() {
+ assertEquals(BillingCategory.API, BillingCategoryClassifier.classify(false, false, true));
+ }
+
+ @Test
+ void bypassedWhenNoSignal() {
+ assertEquals(
+ BillingCategory.BYPASSED, BillingCategoryClassifier.classify(false, false, false));
+ }
+}
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
index 7392eb928a..7c98d5e59b 100644
--- a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java
+++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java
@@ -1,5 +1,8 @@
package stirling.software.saas.accountlink;
+import java.time.LocalDateTime;
+import java.util.Map;
+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
@@ -9,6 +12,7 @@
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -16,11 +20,16 @@
import lombok.extern.slf4j.Slf4j;
+import stirling.software.proprietary.billing.UnitCalcPolicy;
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.instance.InstanceUsageIngestService;
+import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.EntitlementState;
+import stirling.software.saas.payg.policy.PricingPolicy;
+import stirling.software.saas.payg.policy.PricingPolicyService;
/**
* Instance-facing surface (combined-billing "Mode A"), authenticated by the device
@@ -46,14 +55,23 @@ public class InstanceController {
private final EntitlementService entitlementService;
private final TeamBillingService billingService;
private final AccountLinkService accountLinkService;
+ private final PricingPolicyService pricingPolicyService;
+ private final InstanceUsageIngestService usageIngestService;
+ private final LinkedInstanceRepository linkedInstanceRepository;
public InstanceController(
EntitlementService entitlementService,
TeamBillingService billingService,
- AccountLinkService accountLinkService) {
+ AccountLinkService accountLinkService,
+ PricingPolicyService pricingPolicyService,
+ InstanceUsageIngestService usageIngestService,
+ LinkedInstanceRepository linkedInstanceRepository) {
this.entitlementService = entitlementService;
this.billingService = billingService;
this.accountLinkService = accountLinkService;
+ this.pricingPolicyService = pricingPolicyService;
+ this.usageIngestService = usageIngestService;
+ this.linkedInstanceRepository = linkedInstanceRepository;
}
public record WhoAmIResponse(Long instanceId, Long teamId) {}
@@ -68,7 +86,13 @@ public record EntitlementResponse(
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
- String state) {}
+ String state,
+ // Metering inputs the instance needs to cost + bucket its own usage (Phase 2). The
+ // instance computes units locally with this policy and resets its per-period cumulative
+ // counters on the [periodStart, periodEnd) boundary.
+ UnitCalcPolicy unitCalcPolicy,
+ LocalDateTime periodStart,
+ LocalDateTime periodEnd) {}
@GetMapping("/whoami")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
@@ -103,20 +127,96 @@ public ResponseEntity entitlement(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
+ // Drop the cached snapshot first: this low-frequency read gates real-time billable work, so
+ // it must reflect a just-changed subscription/cap at once (the flip is a DB-function write
+ // with no Java event to invalidate on).
+ entitlementService.invalidate(token.getTeamId());
+ return ResponseEntity.ok(buildEntitlement(token.getTeamId()));
+ }
+
+ /** Body for {@code POST /sync}: the instance's cumulative units per category this period. */
+ public record UsageSyncRequest(
+ long syncSeq, LocalDateTime periodStart, CategoryUnits cumulativeUnits) {
+ public record CategoryUnits(long api, long ai, long automation) {}
+ }
+
+ /**
+ * Daily usage sync: the instance reports its cumulative per-category unit totals for the
+ * period; SaaS bills the delta since the last sync (reusing the standard charge path) and
+ * returns the fresh entitlement — so one round-trip both reports usage and refreshes the gate
+ * state.
+ */
+ @PostMapping("/sync")
+ @PreAuthorize("hasRole('LINKED_INSTANCE')")
+ @Transactional
+ public ResponseEntity sync(
+ Authentication auth, @RequestBody UsageSyncRequest req) {
+ if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+ if (req == null || req.periodStart() == null || req.cumulativeUnits() == null) {
+ return ResponseEntity.badRequest().build();
+ }
Long teamId = token.getTeamId();
+ // periodStart is the dedup/regression partition key, so bound a fabricated value to the
+ // snapshot window (current or immediately-prior period, never future).
+ EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
+ LocalDateTime reported = req.periodStart();
+ if (!reported.isBefore(snap.periodEnd())
+ || reported.isBefore(snap.periodStart().minusMonths(1))) {
+ log.warn(
+ "Instance sync for team {} reported implausible periodStart {} (authoritative"
+ + " {}..{}); rejecting.",
+ teamId,
+ reported,
+ snap.periodStart(),
+ snap.periodEnd());
+ return ResponseEntity.badRequest().build();
+ }
+ // Attribute the charge to the admin who linked the instance (the device credential carries
+ // no user). Null is tolerated by the ingest service (it skips + retries next sync).
+ Long actorUserId =
+ linkedInstanceRepository
+ .findById(token.getInstanceId())
+ .map(LinkedInstance::getCreatedByUserId)
+ .orElse(null);
+ UsageSyncRequest.CategoryUnits c = req.cumulativeUnits();
+ usageIngestService.ingest(
+ teamId,
+ actorUserId,
+ req.syncSeq(),
+ req.periodStart(),
+ Map.of(
+ BillingCategory.API, c.api(),
+ BillingCategory.AI, c.ai(),
+ BillingCategory.AUTOMATION, c.automation()));
+ // Drop the cache so the buildEntitlement below (and the portal's next read) reflect the
+ // just-charged delta + moved free-grant balance now, not after the TTL.
+ entitlementService.invalidate(teamId);
+ return ResponseEntity.ok(buildEntitlement(teamId));
+ }
+ /** The entitlement view shared by {@code GET /entitlement} and the {@code /sync} response. */
+ private EntitlementResponse buildEntitlement(Long teamId) {
// Same composition the FE wallet uses: billing facts (subscription, free pool) from
- // TeamBillingService, period spend/cap + state from the entitlement snapshot.
+ // TeamBillingService, period spend/cap + state from the entitlement snapshot, plus the
+ // unit-calc policy + period the instance needs to meter locally.
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())));
+ PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId);
+ return new EntitlementResponse(
+ billing.subscribed(),
+ billing.freeRemainingUnits(),
+ snap.periodSpendUnits(),
+ snap.periodCapUnits(),
+ coarseState(snap.state()),
+ new UnitCalcPolicy(
+ policy.getDocPagesPerUnit(),
+ policy.getDocBytesPerUnit(),
+ policy.getMinChargeUnits(),
+ policy.getFileUnitCap()),
+ snap.periodStart(),
+ snap.periodEnd());
}
/**
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java
index 041eaa8f4a..b79d57117e 100644
--- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java
+++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java
@@ -19,6 +19,7 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -328,6 +329,32 @@ public ResponseEntity updateCap(
/** Request body for {@link #updateCap}. */
public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {}
+ // ---------------------------------------------------------------------------------------
+ // POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh
+ // ---------------------------------------------------------------------------------------
+
+ /**
+ * Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a
+ * billing state that just changed out-of-band. The subscription flip is written by a Postgres
+ * function ({@code payg_link_subscription}) with no Java event to invalidate on, so a client
+ * that knows a change just happened — the portal while finalizing a checkout — pokes the cache
+ * here rather than waiting out the ~30s TTL. Team-scoped to the caller: a client can only
+ * refresh its own team, and a no-team caller is a cheap no-op.
+ */
+ @PostMapping("/wallet/refresh")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity refreshWallet(Authentication auth) {
+ User user;
+ try {
+ user = AuthenticationUtils.getCurrentUser(auth, userRepository);
+ } catch (SecurityException e) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+ primaryMembership(user.getId())
+ .ifPresent(m -> entitlementService.invalidate(m.getTeam().getId()));
+ return ResponseEntity.noContent().build();
+ }
+
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java
index 603f490a4f..04b9b182a2 100644
--- a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java
+++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java
@@ -5,6 +5,7 @@
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -18,6 +19,9 @@
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.jpdfium.PdfDocument;
+import stirling.software.proprietary.billing.DocumentUnitCalculator;
+import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
+import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.saas.payg.policy.PricingPolicy;
/**
@@ -42,9 +46,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
private static final String PDF_CONTENT_TYPE = "application/pdf";
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
- /** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */
- private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
-
private final TempFileManager tempFileManager;
@Override
@@ -59,13 +60,7 @@ public DocumentMetrics classify(
Objects.requireNonNull(policy, "policy");
FileFacts facts = inspect(file, materialisedPath);
- long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy);
- // toIntExact: fail loud on overflow rather than silently wrapping a billing number.
- int units =
- Math.toIntExact(
- Math.max(
- MIN_UNITS_PER_NONEMPTY_FILE,
- Math.min(policy.getFileUnitCap(), rawUnits)));
+ int units = DocumentUnitCalculator.unitsForFile(facts.pages, facts.bytes, unitCalc(policy));
return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units);
}
@@ -93,17 +88,16 @@ public DocumentMetrics classify(
int totalPages = 0;
long totalBytes = 0;
- long rawUnitsSum = 0;
String firstContentType = null;
+ List sizes = new ArrayList<>(files.size());
for (int i = 0; i < files.size(); i++) {
MultipartFile file = files.get(i);
Path path = materialisedPaths == null ? null : materialisedPaths.get(i);
FileFacts facts = inspect(file, path);
- // Sum the *raw* (unclamped) per-file units so the group cap below can actually bind.
- // Per-file clamping in this loop would make the group cap a no-op.
- rawUnitsSum =
- saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy));
+ // Collect raw page/byte facts; the group cap is applied over the raw sum in the
+ // calculator (per-file clamping here would make the group cap a no-op).
+ sizes.add(new FileSize(facts.pages, facts.bytes));
totalPages = saturatedAdd(totalPages, facts.pages);
totalBytes = saturatedAdd(totalBytes, facts.bytes);
if (firstContentType == null) {
@@ -111,13 +105,7 @@ public DocumentMetrics classify(
}
}
- long groupCap = (long) policy.getFileUnitCap() * files.size();
- // toIntExact: fail loud on overflow rather than silently wrapping.
- int totalUnits =
- Math.toIntExact(
- Math.max(
- (long) MIN_UNITS_PER_NONEMPTY_FILE,
- Math.min(groupCap, rawUnitsSum)));
+ int totalUnits = DocumentUnitCalculator.unitsForGroup(sizes, unitCalc(policy));
return new DocumentMetrics(
totalPages,
@@ -126,6 +114,14 @@ public DocumentMetrics classify(
totalUnits);
}
+ private static UnitCalcPolicy unitCalc(PricingPolicy policy) {
+ return new UnitCalcPolicy(
+ policy.getDocPagesPerUnit(),
+ policy.getDocBytesPerUnit(),
+ policy.getMinChargeUnits(),
+ policy.getFileUnitCap());
+ }
+
private FileFacts inspect(MultipartFile file, Path materialisedPath) {
long bytes = file.getSize();
String contentType =
@@ -140,19 +136,6 @@ private FileFacts inspect(MultipartFile file, Path materialisedPath) {
return new FileFacts(pages, bytes, contentType);
}
- private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) {
- long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L;
- long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit());
- return Math.max(pageUnits, byteUnits);
- }
-
- private static long ceilDiv(long numerator, long divisor) {
- if (numerator <= 0) {
- return 0;
- }
- return (numerator + divisor - 1) / divisor;
- }
-
private static boolean isPdf(String contentType, String filename) {
if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) {
return true;
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java
new file mode 100644
index 0000000000..291a46880c
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java
@@ -0,0 +1,130 @@
+package stirling.software.saas.payg.instance;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+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;
+
+import stirling.software.saas.payg.charge.ChargeContext;
+import stirling.software.saas.payg.charge.JobChargeService;
+import stirling.software.saas.payg.model.BillingCategory;
+import stirling.software.saas.payg.model.JobSource;
+import stirling.software.saas.payg.model.ProcessType;
+import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
+
+/**
+ * Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a
+ * monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the
+ * last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split,
+ * ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and
+ * tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The
+ * cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}.
+ */
+@Slf4j
+@Service
+@Profile("saas")
+@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
+public class InstanceUsageIngestService {
+
+ private final PaygInstanceUsageRepository usageRepository;
+ private final JobChargeService chargeService;
+
+ public InstanceUsageIngestService(
+ PaygInstanceUsageRepository usageRepository, JobChargeService chargeService) {
+ this.usageRepository = usageRepository;
+ this.chargeService = chargeService;
+ }
+
+ /**
+ * Bills the delta for each category and advances the last-seen cumulative + sync sequence. The
+ * delta-advance and the charge share this transaction, so a crash before commit re-bills
+ * cleanly on retry (delta unchanged) and a commit means the cumulative moved with the charge.
+ *
+ * @param actorUserId the linking admin ({@code linked_instance.created_by_user_id}); required
+ * to attribute the charge. If {@code null} we skip entirely (don't advance) so a later
+ * sync, once the actor is resolvable, still bills the usage.
+ */
+ @Transactional
+ public void ingest(
+ Long teamId,
+ Long actorUserId,
+ long syncSeq,
+ LocalDateTime periodStart,
+ Map cumulativeByCategory) {
+ if (teamId == null || periodStart == null || cumulativeByCategory == null) {
+ return;
+ }
+ if (actorUserId == null) {
+ log.warn(
+ "Instance usage sync for team {} has no actor (created_by_user_id null); not"
+ + " billing — a later sync will pick it up.",
+ teamId);
+ return;
+ }
+ cumulativeByCategory.forEach(
+ (category, cumulative) -> {
+ if (category == null
+ || category == BillingCategory.BYPASSED
+ || cumulative == null
+ || cumulative < 0) {
+ return;
+ }
+ applyCategory(teamId, actorUserId, syncSeq, periodStart, category, cumulative);
+ });
+ }
+
+ private void applyCategory(
+ Long teamId,
+ Long actorUserId,
+ long syncSeq,
+ LocalDateTime periodStart,
+ BillingCategory category,
+ long cumulative) {
+ // Pessimistic row lock so a duplicate delivery can't have two txns read the same baseline
+ // and both charge: the second waits, then sees the advanced seq and replay-skips.
+ PaygInstanceUsage row =
+ usageRepository
+ .findByTeamIdAndPeriodStartAndCategoryForUpdate(
+ teamId, periodStart, category.name())
+ .orElse(null);
+ if (row != null && syncSeq <= row.getLastSyncSeq()) {
+ return; // replay / out-of-order — already applied this or a later sync
+ }
+ long lastCumulative = row == null ? 0L : row.getLastCumulativeUnits();
+ long delta = cumulative - lastCumulative;
+ if (delta < 0) {
+ // The cumulative counter went backwards — a reset or tampering. Refuse to credit; don't
+ // advance, so the discrepancy stays visible and a corrected resend can reconcile.
+ log.warn(
+ "Instance usage regression team={} category={} reported {} < last {}; ignoring.",
+ teamId,
+ category,
+ cumulative,
+ lastCumulative);
+ return;
+ }
+ if (delta > 0) {
+ int units = (int) Math.min(delta, Integer.MAX_VALUE);
+ chargeService.chargeStandalone(
+ new ChargeContext(
+ actorUserId,
+ teamId,
+ JobSource.LINKED_INSTANCE,
+ ProcessType.SINGLE_TOOL,
+ category),
+ units);
+ }
+ if (row == null) {
+ row = new PaygInstanceUsage(teamId, periodStart, category.name(), cumulative, syncSeq);
+ } else {
+ row.setLastCumulativeUnits(cumulative);
+ row.setLastSyncSeq(syncSeq);
+ }
+ usageRepository.save(row);
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java
new file mode 100644
index 0000000000..45a6435746
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java
@@ -0,0 +1,74 @@
+package stirling.software.saas.payg.instance;
+
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.UpdateTimestamp;
+
+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 jakarta.persistence.UniqueConstraint;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team,
+ * billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative
+ * unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via
+ * the standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
+ */
+@Entity
+@Table(
+ name = "payg_instance_usage",
+ uniqueConstraints =
+ @UniqueConstraint(
+ name = "uk_payg_instance_usage",
+ columnNames = {"team_id", "period_start", "category"}))
+@Getter
+@Setter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class PaygInstanceUsage {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "team_id", nullable = false)
+ private Long teamId;
+
+ @Column(name = "period_start", nullable = false)
+ private LocalDateTime periodStart;
+
+ /** {@code BillingCategory} name — API / AI / AUTOMATION. */
+ @Column(name = "category", nullable = false, length = 32)
+ private String category;
+
+ @Column(name = "last_cumulative_units", nullable = false)
+ private long lastCumulativeUnits;
+
+ @Column(name = "last_sync_seq", nullable = false)
+ private long lastSyncSeq;
+
+ @UpdateTimestamp
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ public PaygInstanceUsage(
+ Long teamId,
+ LocalDateTime periodStart,
+ String category,
+ long lastCumulativeUnits,
+ long lastSyncSeq) {
+ this.teamId = teamId;
+ this.periodStart = periodStart;
+ this.category = category;
+ this.lastCumulativeUnits = lastCumulativeUnits;
+ this.lastSyncSeq = lastSyncSeq;
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java
index 86e6e4ab47..4e62afcbd8 100644
--- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java
+++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java
@@ -1,22 +1,18 @@
package stirling.software.saas.payg.lineage;
import java.io.IOException;
-import java.io.InputStream;
-import java.nio.file.Files;
import java.nio.file.Path;
-import java.security.DigestInputStream;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.HexFormat;
import java.util.Set;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
+import stirling.software.proprietary.billing.ContentHasher;
+
/**
* SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every
- * content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read
- * buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions).
+ * content type, doesn't parse. Delegates to the shared {@link ContentHasher} so the cloud charge
+ * path and a linked self-hosted instance's meter compute byte-identical signatures.
*
*
Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code
* PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed
@@ -26,36 +22,15 @@
@Profile("saas")
public class ByteHashSignatureExtractor implements LineageSignatureExtractor {
- private static final String ALGORITHM = "SHA-256";
private static final String SIGNATURE_TYPE = "sha256";
- private static final int BUFFER_SIZE = 64 * 1024;
@Override
public Set extract(Path file) throws IOException {
- MessageDigest digest = newDigest();
- try (InputStream raw = Files.newInputStream(file);
- DigestInputStream in = new DigestInputStream(raw, digest)) {
- byte[] buf = new byte[BUFFER_SIZE];
- // Drain through the digest stream; we only care about side effects on the digest.
- while (in.read(buf) != -1) {
- // no-op
- }
- }
- String hex = HexFormat.of().formatHex(digest.digest());
- return Set.of(new LineageSignature(SIGNATURE_TYPE, hex));
+ return Set.of(new LineageSignature(SIGNATURE_TYPE, ContentHasher.sha256(file)));
}
@Override
public String name() {
return SIGNATURE_TYPE;
}
-
- private static MessageDigest newDigest() {
- try {
- return MessageDigest.getInstance(ALGORITHM);
- } catch (NoSuchAlgorithmException e) {
- // SHA-256 is mandated by every JDK; unreachable in practice.
- throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
- }
- }
}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java
index 8f7d2b3df7..b023f08fb5 100644
--- a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java
+++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java
@@ -15,5 +15,12 @@ public enum JobSource {
/**
* The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend.
*/
- DESKTOP_APP
+ DESKTOP_APP,
+ /**
+ * Usage reported by a linked self-hosted instance via the daily sync (combined-billing "Mode
+ * A"). The per-request surface is lost in the aggregate — the instance reports cumulative units
+ * per {@code BillingCategory} — so this just marks the charge as instance-synced. No per-source
+ * step limit is seeded for it; the charge path's fallback applies.
+ */
+ LINKED_INSTANCE
}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java
new file mode 100644
index 0000000000..283cc212b1
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java
@@ -0,0 +1,35 @@
+package stirling.software.saas.payg.repository;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import jakarta.persistence.LockModeType;
+
+import stirling.software.saas.payg.instance.PaygInstanceUsage;
+
+/** Last-seen cumulative usage per (team, period, category) for linked-instance daily syncs. */
+public interface PaygInstanceUsageRepository extends JpaRepository {
+
+ Optional findByTeamIdAndPeriodStartAndCategory(
+ Long teamId, LocalDateTime periodStart, String category);
+
+ /**
+ * Pessimistic-write variant the ingest uses so two concurrent deliveries of the same sync (e.g.
+ * a proxy retry) can't both read the same baseline and double-charge the delta. Must run inside
+ * a transaction.
+ */
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query(
+ "SELECT u FROM PaygInstanceUsage u"
+ + " WHERE u.teamId = :teamId AND u.periodStart = :periodStart"
+ + " AND u.category = :category")
+ Optional findByTeamIdAndPeriodStartAndCategoryForUpdate(
+ @Param("teamId") Long teamId,
+ @Param("periodStart") LocalDateTime periodStart,
+ @Param("category") String category);
+}
diff --git a/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql
new file mode 100644
index 0000000000..7ab65b8b78
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql
@@ -0,0 +1,35 @@
+-- Twin of supabase/migrations/_payg_instance_usage.sql (Stirling-PDF-SaaS). Keep the table
+-- definition byte-identical to the Supabase twin — both repos own this stirling_pdf table (the SaaS
+-- profile runs this Flyway migration against the Supabase-backed DB; non-Hibernate consumers — RLS,
+-- PostgREST, edge functions — rely on the Supabase migration ledger having the matching entry).
+--
+-- Per-(team, billing period, category) last-seen cumulative usage reported by a linked self-hosted
+-- instance (combined-billing "Mode A"). The instance reports monotonic cumulative unit totals on
+-- its daily sync; SaaS bills the DELTA since the last sync — idempotent (a resend bills nothing) and
+-- tamper-evident (a counter that drops is a signal) — by reusing the standard charge path
+-- (JobChargeService.chargeStandalone), so no separate billing logic exists for this flow.
+--
+-- Inert until release: written only by the InstanceController /sync endpoint, gated behind
+-- stirling.billing.account-link.enabled (default off). Additive, idempotent table.
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.payg_instance_usage (
+ id BIGSERIAL PRIMARY KEY,
+ team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
+ period_start TIMESTAMP NOT NULL,
+ category VARCHAR(32) NOT NULL,
+ -- Highest cumulative unit total seen for this (team, period, category); the next sync bills
+ -- (reported cumulative - this).
+ last_cumulative_units BIGINT NOT NULL DEFAULT 0,
+ -- Highest sync sequence applied; a sync at or below this is a replay and is ignored.
+ last_sync_seq BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT uk_payg_instance_usage UNIQUE (team_id, period_start, category)
+);
+
+CREATE INDEX IF NOT EXISTS idx_payg_instance_usage_team
+ ON stirling_pdf.payg_instance_usage (team_id);
+
+COMMENT ON TABLE stirling_pdf.payg_instance_usage IS
+ 'Last-seen cumulative usage per (team, billing period, category) reported by linked self-hosted '
+ 'instances (combined-billing Mode A). SaaS bills the delta vs last_cumulative_units via the '
+ 'standard charge path; last_sync_seq dedups replays.';
diff --git a/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql
new file mode 100644
index 0000000000..f79097e325
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql
@@ -0,0 +1,19 @@
+-- Twin of supabase/migrations/_payg_shadow_charge_linked_instance_source.sql (Stirling-PDF-SaaS).
+-- Keep byte-identical to the Supabase twin.
+--
+-- Widen the payg_shadow_charge.job_source CHECK to allow LINKED_INSTANCE (combined-billing "Mode
+-- A"). A linked instance's daily-sync charge runs through JobChargeService.chargeStandalone, which
+-- writes a payg_shadow_charge row with job_source=LINKED_INSTANCE — a JobSource value added after
+-- the original constraint, so the insert was failing the check and 500ing POST /api/v1/instance/sync.
+--
+-- Idempotent (DROP IF EXISTS + ADD, so it survives being applied by both the Flyway and Supabase
+-- migration sets against the same schema) and additive (the new set is a superset of the JobSource
+-- enum; the app only ever writes enum values, so no existing row can violate it).
+
+ALTER TABLE stirling_pdf.payg_shadow_charge
+ DROP CONSTRAINT IF EXISTS payg_shadow_charge_job_source_check;
+
+ALTER TABLE stirling_pdf.payg_shadow_charge
+ ADD CONSTRAINT payg_shadow_charge_job_source_check
+ CHECK (job_source IS NULL
+ OR job_source IN ('WEB', 'API', 'PIPELINE', 'DESKTOP_APP', 'LINKED_INSTANCE'));
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
index d221ac8603..4c9cc3b76a 100644
--- a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java
+++ b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java
@@ -1,6 +1,7 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -8,9 +9,12 @@
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
+import java.util.Map;
+import java.util.Optional;
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 org.springframework.http.HttpStatus;
@@ -19,14 +23,19 @@
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import stirling.software.proprietary.billing.UnitCalcPolicy;
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.instance.InstanceUsageIngestService;
+import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
+import stirling.software.saas.payg.policy.PricingPolicy;
+import stirling.software.saas.payg.policy.PricingPolicyService;
/**
* Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read.
@@ -39,9 +48,22 @@ class InstanceControllerTest {
@Mock private EntitlementService entitlementService;
@Mock private TeamBillingService billingService;
@Mock private AccountLinkService accountLinkService;
+ @Mock private PricingPolicyService pricingPolicyService;
+ @Mock private InstanceUsageIngestService usageIngestService;
+ @Mock private LinkedInstanceRepository linkedInstanceRepository;
private InstanceController controller() {
- return new InstanceController(entitlementService, billingService, accountLinkService);
+ return new InstanceController(
+ entitlementService,
+ billingService,
+ accountLinkService,
+ pricingPolicyService,
+ usageIngestService,
+ linkedInstanceRepository);
+ }
+
+ private static PricingPolicy policy() {
+ return new PricingPolicy(1, 1_048_576L, 1, 1000);
}
@Test
@@ -50,6 +72,7 @@ void entitlement_resolvesTeamFromTokenAndMapsSnapshot() {
when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L));
when(entitlementService.getSnapshot(42L))
.thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L));
+ when(pricingPolicyService.getEffectivePolicy(42L)).thenReturn(policy());
ResponseEntity resp = controller().entitlement(token);
@@ -62,6 +85,13 @@ void entitlement_resolvesTeamFromTokenAndMapsSnapshot() {
assertThat(body.periodCapUnits()).isEqualTo(1250L);
// WARNED is still within budget for the gate's purposes → coarse OK.
assertThat(body.state()).isEqualTo("OK");
+ // Phase 2: the metering inputs the instance needs ride along.
+ assertThat(body.unitCalcPolicy()).isEqualTo(new UnitCalcPolicy(1, 1_048_576L, 1, 1000));
+ assertThat(body.periodStart()).isNotNull();
+ assertThat(body.periodEnd()).isNotNull();
+ // The instance-facing read drops the cached snapshot first so a just-subscribed team's
+ // plan surfaces on the next poll instead of waiting out the cache TTL.
+ verify(entitlementService).invalidate(42L);
}
@Test
@@ -70,6 +100,7 @@ void entitlement_uncapped_returnsNullCapUnits() {
when(billingService.forTeam(7L)).thenReturn(freeBilling(500L));
when(entitlementService.getSnapshot(7L))
.thenReturn(snapshot(EntitlementState.FULL, 0L, null));
+ when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy());
ResponseEntity resp = controller().entitlement(token);
@@ -89,6 +120,7 @@ void entitlement_degradedMapsToOverLimit() {
when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L));
when(entitlementService.getSnapshot(8L))
.thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L));
+ when(pricingPolicyService.getEffectivePolicy(8L)).thenReturn(policy());
EntitlementResponse body = controller().entitlement(token).getBody();
@@ -110,6 +142,59 @@ void entitlement_nonInstancePrincipalIsRejected() {
verifyNoInteractions(entitlementService, billingService);
}
+ @Test
+ void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() {
+ Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L);
+ LinkedInstance li = new LinkedInstance();
+ li.setCreatedByUserId(7L);
+ LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
+ when(linkedInstanceRepository.findById(4L)).thenReturn(Optional.of(li));
+ when(billingService.forTeam(99L)).thenReturn(freeBilling(10L));
+ // The reported periodStart is validated against the authoritative snapshot period.
+ when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null));
+ when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy());
+
+ InstanceController.UsageSyncRequest req =
+ new InstanceController.UsageSyncRequest(
+ 3L,
+ period,
+ new InstanceController.UsageSyncRequest.CategoryUnits(12, 4, 8));
+
+ ResponseEntity resp = controller().sync(token, req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor