Merge branch 'main' into fix_win_desktop_task

This commit is contained in:
Ludy
2026-07-06 14:02:22 +02:00
committed by GitHub
62 changed files with 3552 additions and 304 deletions
@@ -6,6 +6,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -14,25 +15,31 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Two calls:
* <p>Calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
* cumulative units and returns the refreshed entitlement.
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
* /api/v1/instance/revoke-self}).
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
* endpoint.
* <p>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 class AccountLinkClient {
}
/**
* 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 class AccountLinkClient {
}
/**
* 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 class AccountLinkClient {
}
}
/**
* 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<String> 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 @@ public class AccountLinkClient {
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. */
@@ -2,6 +2,7 @@ package stirling.software.proprietary.accountlink;
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 @@ import lombok.extern.slf4j.Slf4j;
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card.
* 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).
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
@@ -38,9 +41,17 @@ import lombok.extern.slf4j.Slf4j;
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<UsageSyncService> syncServiceProvider;
public AccountLinkController(AccountLinkService service) {
public AccountLinkController(
AccountLinkService service,
LocalUsageService localUsageService,
ObjectProvider<UsageSyncService> 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 class AccountLinkController {
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<LocalUsageService.LocalUsage> 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<Void> syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
if (sync == null) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
sync.syncNow();
return ResponseEntity.noContent().build();
}
}
@@ -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, <b>separate</b> 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);
}
}
@@ -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").
*
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> 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.
*
* <p>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;
}
@@ -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<AccountLinkSyncState, Long> {}
@@ -3,14 +3,26 @@ package stirling.software.proprietary.accountlink;
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 <b>billable</b> (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):
*
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
* sub-steps). Everything else — interactive manual PDF tools — is always free.
* <ul>
* <li><b>AUTOMATION</b> — the automation marker header ({@link
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
* <li><b>AI</b> — the AI surface ({@code /api/v1/ai/**});
* <li><b>API</b> — an API-key authenticated tool call;
* <li><b>BYPASSED</b> — a manual interactive tool call, never billed.
* </ul>
*
* <p>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 /<ctx>/api/v1/ai/** still classifies as billable.
// context path so /<ctx>/api/v1/ai/** still classifies as AI.
String ctx = request.getContextPath();
String path =
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
@@ -12,18 +12,13 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
* every billable request. Single-slot (one instance = one linked team), TTL-based.
* 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.
*
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
* allow").
*
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
* rather than serving a stale entitled snapshot.
* <p>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 class EntitlementCache {
* not linked or the SaaS side is unreachable and we have no prior snapshot.
*/
public Optional<InstanceEntitlement> current() {
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
// call) while concurrent callers serve the last snapshot — no thundering herd of
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
// 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 class EntitlementCache {
}
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<DeviceCredential> cred = credentialStore.get();
@@ -101,15 +94,15 @@ public class EntitlementCache {
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 @@ public class EntitlementCache {
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());
}
}
}
@@ -16,6 +16,11 @@ public record GateDecision(boolean allowed, Reason 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. */
@@ -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.
*
* <p>The first five fields are what the <b>gate</b> 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);
}
}
@@ -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 @@ import org.springframework.stereotype.Service;
* <li>Flag off → always allow (feature inert).
* <li>Manual tool → always allow (manual tools are free, never metered).
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, 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.
* <li>Billable + linked + entitled → allow.
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
* </ol>
*
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
* live flag / linked-state / entitlement. This is the unit-tested core.
* <p>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 class InstanceEntitlementGate {
boolean linked = credentialStore.isLinked();
Optional<InstanceEntitlement> 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<InstanceEntitlement> entitlement) {
Optional<InstanceEntitlement> entitlement,
boolean graceExpired,
long pendingUnsyncedUnits) {
if (!flagEnabled) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
@@ -75,30 +106,69 @@ public class InstanceEntitlementGate {
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;
}
}
@@ -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.
*
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
* responses). Fail-open and flag-off both let the request continue.
*
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
* <p>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 @@ import lombok.extern.slf4j.Slf4j;
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
private final InstanceEntitlementGate gate;
private static final String ATTR_CATEGORY =
InstanceEntitlementInterceptor.class.getName() + ".category";
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
private final InstanceEntitlementGate gate;
private final EntitlementCache entitlementCache;
private final ObjectProvider<UsageMeterService> meterProvider;
private final TempFileManager tempFileManager;
public InstanceEntitlementInterceptor(
InstanceEntitlementGate gate,
EntitlementCache entitlementCache,
ObjectProvider<UsageMeterService> meterProvider,
TempFileManager tempFileManager) {
this.gate = gate;
this.entitlementCache = entitlementCache;
this.meterProvider = meterProvider;
this.tempFileManager = tempFileManager;
}
@Override
@@ -41,7 +78,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
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 class InstanceEntitlementInterceptor implements HandlerInterceptor {
+ "\"}");
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<TempFile> temps = new ArrayList<>();
try {
List<FileSize> sizes = new ArrayList<>();
List<String> hashes = new ArrayList<>();
int fileCount = 0;
for (List<MultipartFile> 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<String> hashes) {
List<String> 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");
}
}
@@ -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.
*
* <p>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<BillingCategory, Long> 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);
}
}
@@ -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 <b>workflow
* window</b>: 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.
*
* <p>{@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.
*
* <p>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;
}
}
@@ -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<MeteredInputSignature, Long> {
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
LocalDateTime periodStart, String signature);
}
@@ -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);
}
}
@@ -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<UsageCounter, Long> {
/**
* 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<UsageCounter> 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<LocalDateTime> 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);
}
@@ -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.
*
* <p>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());
}
}
}
@@ -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.
*
* <p>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<DeviceCredential> cred = credentialStore.get();
if (cred.isEmpty()) {
return; // not linked
}
List<LocalDateTime> 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<BillingCategory, Long> 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<BillingCategory, Long> 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;
});
}
}
@@ -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.
*
* <p>Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link
* BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
* billed.
*
* <p>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
}
@@ -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}.
*
* <p>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;
}
}
@@ -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 <em>identical</em> 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.
*
* <p>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);
}
}
}
@@ -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}.
*
* <p>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
* <em>raw</em> per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not
* per-file-clamped, units so the group cap can actually bind).
*
* <p>{@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<FileSize> 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;
}
}
}
@@ -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.
*
* <p>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");
}
}
}
@@ -12,6 +12,7 @@ import java.net.ConnectException;
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 @@ class AccountLinkClientTest {
.thenThrow(new ConnectException("refused"));
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception {
HttpResponse<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
InstanceEntitlement e =
client.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<String> 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<String> 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));
}
}
@@ -2,12 +2,15 @@ package stirling.software.proprietary.accountlink;
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 @@ import stirling.software.proprietary.accountlink.AccountLinkController.LinkReque
class AccountLinkControllerTest {
private AccountLinkService service;
private UsageSyncService syncService;
private ObjectProvider<UsageSyncService> 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 @@ class AccountLinkControllerTest {
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<Void> 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<Void> resp = controller.syncNow();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
verify(syncService, never()).syncNow();
}
}
@@ -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 {
@Test
void aiPathIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
assertTrue(BillableOperationClassifier.isBillable(req));
private static MockHttpServletRequest req(String uri) {
return new MockHttpServletRequest("POST", uri);
}
@Test
void automationHeaderIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
void aiPathIsAi() {
assertEquals(
BillingCategory.AI,
BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false));
}
@Test
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 plainManualToolIsFree() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
assertFalse(BillableOperationClassifier.isBillable(req));
void apiKeyToolCallIsApi() {
assertEquals(
BillingCategory.API,
BillableOperationClassifier.categorize(req("/api/v1/general/merge"), 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 plainManualToolIsBypassed() {
assertEquals(
BillingCategory.BYPASSED,
BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false));
}
@Test
void aiPathUnderContextPathIsBillable() {
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
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 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 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 aiPathUnderContextPathIsAi() {
// A real context-path deployment still classifies: /<ctx>/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));
}
}
@@ -3,20 +3,32 @@ package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.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 @@ class InstanceEntitlementGateTest {
@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 @@ class InstanceEntitlementGateTest {
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 @@ class InstanceEntitlementGateTest {
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 @@ class InstanceEntitlementGateTest {
@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 @@ class InstanceEntitlementGateTest {
// 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());
}
@@ -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 @@ class InstanceEntitlementGateWiringTest {
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 @@ class InstanceEntitlementGateWiringTest {
.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());
@@ -3,24 +3,55 @@ package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.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<UsageMeterService> 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 @@ class InstanceEntitlementInterceptorTest {
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();
}
}
}
@@ -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);
}
}
@@ -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
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -16,11 +20,16 @@ import io.swagger.v3.oas.annotations.Hidden;
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 <b>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 class InstanceController {
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 class InstanceController {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Long teamId = token.getTeamId();
// 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<EntitlementResponse> 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());
}
/**
@@ -19,6 +19,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.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 class PaygWalletController {
/** 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<Void> 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
// ---------------------------------------------------------------------------------------
@@ -5,6 +5,7 @@ import java.io.InputStream;
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 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.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 class DefaultDocumentClassifier implements DocumentClassifier {
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 class DefaultDocumentClassifier implements DocumentClassifier {
int totalPages = 0;
long totalBytes = 0;
long rawUnitsSum = 0;
String firstContentType = null;
List<FileSize> 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 class DefaultDocumentClassifier implements DocumentClassifier {
}
}
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 class DefaultDocumentClassifier implements DocumentClassifier {
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 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
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;
@@ -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<BillingCategory, Long> 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);
}
}
@@ -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;
}
}
@@ -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.
*
* <p>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 @@ import org.springframework.stereotype.Component;
@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<LineageSignature> 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);
}
}
}
@@ -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
}
@@ -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<PaygInstanceUsage, Long> {
Optional<PaygInstanceUsage> 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<PaygInstanceUsage> findByTeamIdAndPeriodStartAndCategoryForUpdate(
@Param("teamId") Long teamId,
@Param("periodStart") LocalDateTime periodStart,
@Param("category") String category);
}
@@ -0,0 +1,35 @@
-- Twin of supabase/migrations/<ts>_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.';
@@ -0,0 +1,19 @@
-- Twin of supabase/migrations/<ts>_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'));
@@ -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 static org.mockito.Mockito.when;
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.authentication.AnonymousAuthenticationToken;
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 @@ class InstanceControllerTest {
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<EntitlementResponse> resp = controller().entitlement(token);
@@ -62,6 +85,13 @@ class InstanceControllerTest {
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 @@ class InstanceControllerTest {
when(billingService.forTeam(7L)).thenReturn(freeBilling(500L));
when(entitlementService.getSnapshot(7L))
.thenReturn(snapshot(EntitlementState.FULL, 0L, null));
when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy());
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
@@ -89,6 +120,7 @@ class InstanceControllerTest {
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 @@ class InstanceControllerTest {
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<EntitlementResponse> resp = controller().sync(token, req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<BillingCategory, Long>> cumulative = ArgumentCaptor.forClass(Map.class);
verify(usageIngestService)
.ingest(eq(99L), eq(7L), eq(3L), eq(period), cumulative.capture());
assertThat(cumulative.getValue())
.containsEntry(BillingCategory.API, 12L)
.containsEntry(BillingCategory.AI, 4L)
.containsEntry(BillingCategory.AUTOMATION, 8L);
// The sync drops the team's cached snapshot so the just-charged delta (and the free-grant
// balance it moved) show on the next wallet read instead of lagging out the 30s TTL.
verify(entitlementService).invalidate(99L);
}
@Test
void sync_rejectsImplausiblePeriodStart() {
Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L);
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null));
// A fabricated far-future periodStart (would reset the dedup partition) → 400, no ingest.
InstanceController.UsageSyncRequest req =
new InstanceController.UsageSyncRequest(
1L,
period.plusYears(5),
new InstanceController.UsageSyncRequest.CategoryUnits(99, 0, 0));
ResponseEntity<EntitlementResponse> resp = controller().sync(token, req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verifyNoInteractions(usageIngestService);
}
@Test
void revokeSelf_callsServiceWithTokenIdentityAndReturns204() {
Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L);
@@ -187,4 +272,17 @@ class InstanceControllerTest {
start.plusMonths(1),
false);
}
/** Snapshot with an explicit period — the sync tests need a deterministic period window. */
private static EntitlementSnapshot snapshotForPeriod(LocalDateTime start, Long cap) {
return new EntitlementSnapshot(
EntitlementState.FULL,
FeatureSet.FULL,
List.of(FeatureGate.OFFSITE_PROCESSING),
0L,
cap,
start,
start.plusMonths(1),
false);
}
}
@@ -432,6 +432,52 @@ class PaygWalletControllerTest {
verifyNoInteractions(policyRepo, entitlementService);
}
// -----------------------------------------------------------------------------------------
// POST /wallet/refresh
// -----------------------------------------------------------------------------------------
@Test
void refreshWallet_dropsCallerTeamCache() {
User user = userWithId(30L, UUID.randomUUID());
Team team = teamWithId(70L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(30L))
.thenReturn(List.of(membership(team, user, TeamRole.MEMBER)));
ResponseEntity<Void> resp = controller.refreshWallet(jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
// Portal pokes this after checkout so the next /wallet read reflects the subscription
// immediately rather than after the cache TTL.
verify(entitlementService).invalidate(70L);
}
@Test
void refreshWallet_noTeam_isNoOpButOk() {
User user = userWithId(31L, UUID.randomUUID());
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(31L)).thenReturn(List.of());
ResponseEntity<Void> resp = controller.refreshWallet(jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
verify(entitlementService, never()).invalidate(any());
}
@Test
void refreshWallet_anonymousIs401() {
Authentication anon =
new AnonymousAuthenticationToken(
"k",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
ResponseEntity<Void> resp = controller.refreshWallet(anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(entitlementService);
}
// -----------------------------------------------------------------------------------------
// Fixtures
// -----------------------------------------------------------------------------------------
@@ -913,6 +913,52 @@ class JobChargeServiceTest {
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void chargeStandalone_floorsUnitsAtMinChargeUnits() {
// Pins the per-call minChargeUnits floor that the linked-instance sync path inherits: a
// daily delta below the floor bills the floor (max(delta, minChargeUnits)) — applied per
// sync-delta here, not per underlying op (documented divergence from the in-cloud per-op
// floor; can only under-bill vs per-op, never over).
long teamId = 100L;
PricingPolicy policy = stubPolicy(/*minCharge*/ 5, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(teamId)).thenReturn(policy);
UUID jobId = UUID.randomUUID();
when(jobService.open(any(JobContext.class), eq(5))).thenReturn(openJob(jobId));
when(jobService.close(jobId)).thenReturn(openJob(jobId));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(teamId);
ext.setStripeCustomerId("cus_x");
ext.setPaygSubscriptionId("sub_x");
ext.setFreeUnitsRemaining(0L);
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
.thenReturn(
Optional.of(chargedShadowRow(jobId, teamId, 5, 0, BillingCategory.API)));
ChargeContext ctx =
new ChargeContext(
7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API);
ArgumentCaptor<WalletLedgerEntry> ledger = ArgumentCaptor.forClass(WalletLedgerEntry.class);
withTransactionSynchronization(() -> service.chargeStandalone(ctx, 2));
// Delta of 2 floored to minChargeUnits=5: the job, ledger debit, and meter all use 5.
verify(jobService).open(any(JobContext.class), eq(5));
verify(ledgerRepo).save(ledger.capture());
assertThat(ledger.getValue().getAmountUnits()).isEqualTo(-5);
verify(meterReporter)
.recordUsage(
eq(teamId),
eq("cus_x"),
eq(5),
eq(BillingCategory.API),
eq("process:" + jobId + ":close"),
eq(jobId));
}
private static void withTransactionSynchronization(Runnable body) {
TransactionSynchronizationManager.initSynchronization();
try {
@@ -0,0 +1,135 @@
package stirling.software.saas.payg.instance;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.saas.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.repository.PaygInstanceUsageRepository;
@ExtendWith(MockitoExtension.class)
class InstanceUsageIngestServiceTest {
@Mock private PaygInstanceUsageRepository repo;
@Mock private JobChargeService chargeService;
private InstanceUsageIngestService service;
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
@BeforeEach
void setUp() {
service = new InstanceUsageIngestService(repo, chargeService);
}
@Test
void firstSyncChargesFullCumulativeAndSavesRow() {
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "AI"))
.thenReturn(Optional.empty());
service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.AI, 10L));
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
verify(chargeService).chargeStandalone(ctx.capture(), eq(10));
assertThat(ctx.getValue().ownerTeamId()).isEqualTo(1L);
assertThat(ctx.getValue().ownerUserId()).isEqualTo(7L);
assertThat(ctx.getValue().billingCategory()).isEqualTo(BillingCategory.AI);
assertThat(ctx.getValue().source()).isEqualTo(JobSource.LINKED_INSTANCE);
ArgumentCaptor<PaygInstanceUsage> row = ArgumentCaptor.forClass(PaygInstanceUsage.class);
verify(repo).save(row.capture());
assertThat(row.getValue().getLastCumulativeUnits()).isEqualTo(10L);
assertThat(row.getValue().getLastSyncSeq()).isEqualTo(1L);
}
@Test
void secondSyncChargesOnlyDelta() {
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 10L, 1L);
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.of(existing));
service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L));
// One charge for the aggregated delta (15), not per underlying op — pins the per-delta
// model.
verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(15));
verify(repo).save(existing);
assertThat(existing.getLastCumulativeUnits()).isEqualTo(25L);
assertThat(existing.getLastSyncSeq()).isEqualTo(2L);
}
@Test
void replayIsIgnored() {
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.of(existing));
service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L));
verify(chargeService, never()).chargeStandalone(any(), anyInt());
verify(repo, never()).save(any());
}
@Test
void regressionIsRefusedAndNotAdvanced() {
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.of(existing));
service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 5L));
verify(chargeService, never()).chargeStandalone(any(), anyInt());
verify(repo, never()).save(any());
}
@Test
void zeroDeltaAdvancesSeqWithoutCharging() {
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.of(existing));
service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 25L));
verify(chargeService, never()).chargeStandalone(any(), anyInt());
verify(repo).save(existing);
assertThat(existing.getLastSyncSeq()).isEqualTo(3L);
}
@Test
void billsAccruedDeltaWithoutConsultingCap() {
// Intent pin: the ingest has no cap input and always bills the accrued delta — cap
// enforcement is the request-time gate's job (the instance stops accruing at the cap), not
// this aggregate charge path's. A large valid delta is billed in full.
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.empty());
service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.API, 5_000_000L));
verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(5_000_000));
}
@Test
void nullActorSkipsEntirely() {
service.ingest(1L, null, 1L, period, Map.of(BillingCategory.AI, 10L));
verifyNoInteractions(repo, chargeService);
}
}
@@ -6229,9 +6229,19 @@ noClientSecret = "Edge function returned no client_secret."
subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest."
title = "Turn on the Processor plan"
[portal.billing.checkout.activationSlow]
body = "Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly."
close = "Close"
title = "Almost there"
[portal.billing.checkout.error]
title = "Couldn't start checkout"
[portal.billing.checkout.finalizing]
body = "Your payment went through. We're switching on metered processing across your linked instances - this usually takes a few seconds."
hint = "Please keep this window open."
title = "Activating your Processor plan..."
[portal.billing.checkout.notConfigured]
bodyAfter = "in the portal env to enable in-app checkout."
bodyBefore = "Set"
@@ -7746,10 +7756,6 @@ loadWallet = "Couldn't load wallet"
openStripePortal = "Couldn't open Stripe portal"
walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}"
[portal.usage.finalizing]
body = "It can take a few seconds for your subscription to activate. This page updates automatically."
title = "Finalizing your subscription…"
[portal.usage.sessionExpired]
action = "Sign in again"
body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked."
+12
View File
@@ -21,6 +21,18 @@ export async function fetchWallet(): Promise<Wallet> {
return apiClient.saas.json<Wallet>("/api/v1/payg/wallet");
}
/**
* Force the SaaS to drop this team's cached wallet snapshot so the next
* {@link fetchWallet} reflects a just-changed billing state (e.g. a completed
* checkout) without waiting out the ~30s server-side cache. Best-effort — the
* caller polls regardless of whether this succeeds.
*/
export async function refreshWalletCache(): Promise<void> {
await apiClient.saas.json<void>("/api/v1/payg/wallet/refresh", {
method: "POST",
});
}
// ────────────────────────────────────────────────────────────────────────────
// Cap — leader-only PATCH (real endpoint).
// ────────────────────────────────────────────────────────────────────────────
@@ -32,6 +32,7 @@ vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local");
import {
fetchInstances,
fetchLocalUsage,
fetchStatus,
linkInstance,
revokeInstance,
@@ -74,6 +75,16 @@ describe("api/link — local backend (this instance)", () => {
expect((await fetchStatus()).linked).toBe(false);
});
it("reads instance-local unsynced usage via the local endpoint", async () => {
const usage = await fetchLocalUsage();
expect(usage.totalUnsyncedUnits).toBe(
usage.apiUnsyncedUnits +
usage.aiUnsyncedUnits +
usage.automationUnsyncedUnits,
);
expect(usage.totalUnsyncedUnits).toBeGreaterThanOrEqual(0);
});
it("forwards the SaaS JWT in the link body", async () => {
let seenBody: unknown = null;
server.events.on("request:start", async ({ request }) => {
+22
View File
@@ -3,12 +3,14 @@ import type {
LinkInstanceRequest,
LinkStatus,
LinkedInstanceRow,
LocalUsage,
} from "@portal/mocks/link";
export type {
LinkInstanceRequest,
LinkStatus,
LinkedInstanceRow,
LocalUsage,
} from "@portal/mocks/link";
/**
@@ -57,6 +59,15 @@ export async function fetchStatus(): Promise<LinkStatus> {
return apiClient.local.json<LinkStatus>(`${BASE}/status`);
}
/**
* Locally-accrued usage not yet reported to SaaS — the portal adds this on top
* of the SaaS-synced spend so "current usage" includes work done since the last
* daily sync. Local-backend call; returns zeros when metering is off.
*/
export async function fetchLocalUsage(): Promise<LocalUsage> {
return apiClient.local.json<LocalUsage>(`${BASE}/usage`);
}
/**
* Drop this instance's link. The local backend best-effort tells SaaS to
* revoke before clearing the credential locally, then returns 204 — there's no
@@ -66,6 +77,17 @@ export async function unlinkInstance(): Promise<void> {
await apiClient.local.json<void>(`${BASE}/unlink`, { method: "POST" });
}
/**
* Nudge the local backend to sync + refresh its cached entitlement now. Called
* right after a checkout completes so the instance's request-time gate reflects
* the new subscription immediately instead of waiting out its entitlement-cache
* TTL. Best-effort — the caller swallows failures (metering off → 409, or the
* local backend unreachable); the scheduled sync / TTL refresh is the backstop.
*/
export async function triggerLocalSync(): Promise<void> {
await apiClient.local.json<void>(`${BASE}/sync-now`, { method: "POST" });
}
/**
* Every linked instance for the team — SaaS-direct call with the admin's
* Supabase JWT (no longer takes an accessToken parameter; the saas client
@@ -116,6 +116,11 @@ export async function createCheckoutSession(
currency: req.currency ?? "usd",
success_url: req.successUrl,
cancel_url: req.cancelUrl,
// The portal drives an in-page onComplete handler (the checkout modal stays open to
// finalise activation + nudge the linked instance), so tell the edge function not to
// redirect on completion. A redirect would reload the page, skip that finalize step, and
// make Stripe ignore onComplete entirely (console warns "redirect_on_completion: always").
redirect_on_completion: "never",
...(req.billingOwnerEmail
? { billing_owner_email: req.billingOwnerEmail }
: {}),
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, StatusBadge } from "@app/ui";
import type { Wallet } from "@portal/api/billing";
import type { LocalUsage } from "@portal/api/link";
import type { SaasCurrency } from "@portal/billing/stripe";
import { WalletMeter } from "@portal/components/billing/WalletMeter";
import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard";
@@ -10,8 +11,14 @@ import { StripeCheckoutModal } from "@portal/components/billing/StripeCheckoutMo
interface Props {
wallet: Wallet;
/** Called after checkout completes so the parent refetches the wallet. */
onSubscribed?: () => void;
/** Instance-local usage not yet synced to SaaS; folded into the trial meter. */
unsynced?: LocalUsage | null;
/**
* Runs the post-checkout activation poll and resolves true once the wallet
* reads subscribed (false if it's lagging past the poll window). The checkout
* modal awaits this to stay open through activation.
*/
onSubscribed?: () => Promise<boolean>;
}
function isSaasCurrency(c: string | null): c is SaasCurrency {
@@ -23,7 +30,7 @@ function isSaasCurrency(c: string | null): c is SaasCurrency {
* editor fleet, the Processor trial meter (with the inline "Switch on the
* Processor" CTA → embedded Stripe Checkout), and the Enterprise upsell.
*/
export function FreePlanView({ wallet, onSubscribed }: Props) {
export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) {
const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
const [missingTeam, setMissingTeam] = useState<string | null>(null);
@@ -86,7 +93,11 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
<FreePdfEditorsCard />
{/* Processor trial — meter with the inline upgrade CTA */}
<WalletMeter wallet={wallet} action={switchOnAction} />
<WalletMeter
wallet={wallet}
unsynced={unsynced}
action={switchOnAction}
/>
{missingTeam && (
<Banner
@@ -117,10 +128,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
onClose={() => setModalOpen(false)}
teamId={wallet.teamId}
currency={currency}
onComplete={() => {
setModalOpen(false);
onSubscribed?.();
}}
onComplete={() => onSubscribed?.() ?? Promise.resolve(false)}
/>
)}
</div>
@@ -14,6 +14,20 @@ type Story = StoryObj<typeof PdfsProcessedCard>;
/** Metered PDFs split across API / Agents / Automation (real categoryBreakdown). */
export const WithBreakdown: Story = { args: { wallet: subscribedWallet } };
/** Synced usage plus instance-local work not yet billed — headline + split combine, with a pending note. */
export const WithUnsynced: Story = {
args: {
wallet: subscribedWallet,
unsynced: {
periodStart: subscribedWallet.billingPeriodStart,
apiUnsyncedUnits: 12,
aiUnsyncedUnits: 3,
automationUnsyncedUnits: 0,
totalUnsyncedUnits: 15,
},
},
};
/** Nothing metered yet this period — the split hides. */
export const Empty: Story = {
args: {
@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import { Card } from "@app/ui";
import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing";
import type { LocalUsage } from "@portal/api/link";
/**
* "PDFs processed this period" headline + a stacked split of where the metered
@@ -8,6 +9,11 @@ import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing";
* (API / Agents / Automation — the same buckets the entitlement service tracks;
* the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when
* nothing metered has run yet.
*
* <p>When a linked instance has accrued usage SaaS hasn't billed yet ({@code
* unsynced}), it's folded into the headline + split so "current usage" reflects
* work done since the last daily sync. The synced-vs-pending split is an internal
* detail the customer doesn't need, so it's not surfaced — just the combined total.
*/
const SEGMENTS: ReadonlyArray<{
key: keyof WalletCategoryBreakdown;
@@ -43,10 +49,25 @@ const SEGMENTS: ReadonlyArray<{
},
];
export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
export function PdfsProcessedCard({
wallet,
unsynced,
}: {
wallet: Wallet;
unsynced?: LocalUsage | null;
}) {
const { t } = useTranslation();
const b = wallet.categoryBreakdown;
// Fold instance-local unsynced usage into both the headline and the split, so
// the card shows synced + not-yet-billed work as a single current-usage figure.
const pending = unsynced?.totalUnsyncedUnits ?? 0;
const base = wallet.categoryBreakdown;
const b: WalletCategoryBreakdown = {
api: base.api + (unsynced?.apiUnsyncedUnits ?? 0),
ai: base.ai + (unsynced?.aiUnsyncedUnits ?? 0),
automation: base.automation + (unsynced?.automationUnsyncedUnits ?? 0),
};
const total = b.api + b.ai + b.automation;
const headline = wallet.billableUsed + pending;
return (
<Card padding="loose">
@@ -58,7 +79,7 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
</span>
<div className="portal-billing__bignum-row">
<span className="portal-billing__bignum">
{wallet.billableUsed.toLocaleString()}
{headline.toLocaleString()}
</span>
<span className="portal-billing__bignum-unit">
{t("portal.billing.pdfsProcessed.unit", "metered PDFs")}
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Modal, Skeleton } from "@app/ui";
import { Banner, Button, Modal, Skeleton, Spinner } from "@app/ui";
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
@@ -22,10 +22,14 @@ interface Props {
/** Optional billing email prefill (Stripe locks the field when set). */
billingOwnerEmail?: string;
/**
* Fired when Stripe (or the mock continue button) signals success. Caller
* refreshes the wallet so the linked-subscribed view takes over.
* Fired when Stripe (or the mock continue button) signals payment success.
* Runs the caller's activation flow (poll the wallet until the subscription
* webhook lands) and resolves {@code true} once subscribed, {@code false} if
* it's taking longer than the poll window. The modal stays open and
* non-dismissable while this runs, so the admin watches activation through
* instead of the modal vanishing and needing a manual refresh.
*/
onComplete: () => void;
onComplete: () => Promise<boolean>;
}
/**
@@ -45,6 +49,55 @@ function loadStripeOnce(pk: string): Promise<Stripe | null> {
return stripePromise;
}
/** Payment done, waiting for the subscription webhook to activate the plan. */
function CheckoutFinalizing() {
const { t } = useTranslation();
return (
<div className="portal-billing__checkout-finalizing" role="status">
<Spinner size="lg" />
<h3 className="portal-billing__checkout-status-title">
{t(
"portal.billing.checkout.finalizing.title",
"Activating your Processor plan...",
)}
</h3>
<p className="portal-billing__checkout-status-body">
{t(
"portal.billing.checkout.finalizing.body",
"Your payment went through. We're switching on metered processing across your linked instances - this usually takes a few seconds.",
)}
</p>
<p className="portal-billing__checkout-status-hint">
{t(
"portal.billing.checkout.finalizing.hint",
"Please keep this window open.",
)}
</p>
</div>
);
}
/** Webhook lagging past the poll window — dismissable "it'll appear shortly" notice. */
function CheckoutActivationSlow({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
return (
<div className="portal-billing__checkout-finalizing" role="status">
<h3 className="portal-billing__checkout-status-title">
{t("portal.billing.checkout.activationSlow.title", "Almost there")}
</h3>
<p className="portal-billing__checkout-status-body">
{t(
"portal.billing.checkout.activationSlow.body",
"Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly.",
)}
</p>
<Button variant="outline" onClick={onClose}>
{t("portal.billing.checkout.activationSlow.close", "Close")}
</Button>
</div>
);
}
export function StripeCheckoutModal({
open,
onClose,
@@ -57,16 +110,29 @@ export function StripeCheckoutModal({
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// "checkout" = Stripe form; "finalizing" = payment done, waiting for the plan to activate
// (non-dismissable); "activationSlow" = webhook lagging past the poll window (dismissable).
const [phase, setPhase] = useState<
"checkout" | "finalizing" | "activationSlow"
>("checkout");
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const publishableKey = getStripePublishableKey();
// Mint the checkout session whenever the modal opens for a fresh team/currency.
useEffect(() => {
if (!open) {
// Reset on close so re-opening fetches a fresh session.
// Reset on close so re-opening fetches a fresh session + starts at checkout.
setClientSecret(null);
setError(null);
setLoading(true);
setPhase("checkout");
return;
}
let cancelled = false;
@@ -115,57 +181,91 @@ export function StripeCheckoutModal({
const stripe = publishableKey ? loadStripeOnce(publishableKey) : null;
const canRender = Boolean(stripe && clientSecret);
// Payment succeeded — hold the modal open and run the caller's activation poll instead of
// closing. The parent swaps to the subscribed view on success (unmounting us); a lagging
// webhook drops us into the dismissable "almost there" state.
function handleStripeComplete() {
setPhase("finalizing");
onComplete()
.then((activated) => {
if (!activated && mounted.current) setPhase("activationSlow");
})
.catch(() => {
if (mounted.current) setPhase("activationSlow");
});
}
// Block dismissal while activation is in flight so a half-finished flow can't be abandoned;
// the X, backdrop, and Escape all route through this.
const dismissable = phase !== "finalizing";
const handleClose = () => {
if (dismissable) onClose();
};
return (
<Modal
open={open}
onClose={onClose}
width="lg"
onClose={handleClose}
width="xl"
className="portal-billing__checkout-modal"
disableBackdropClose={!dismissable}
disableEscapeClose={!dismissable}
title={t("portal.billing.checkout.title", "Turn on the Processor plan")}
subtitle={t(
"portal.billing.checkout.subtitle",
"Add a card to keep going past your free Editor-plan grant. Stripe handles the rest.",
)}
>
{!publishableKey && (
<Banner
tone="neutral"
title={t(
"portal.billing.checkout.notConfigured.title",
"Stripe not configured",
)}
>
{t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "}
<code>VITE_STRIPE_PUBLISHABLE_KEY</code>{" "}
{t(
"portal.billing.checkout.notConfigured.bodyAfter",
"in the portal env to enable in-app checkout.",
)}
</Banner>
{phase === "finalizing" && <CheckoutFinalizing />}
{phase === "activationSlow" && (
<CheckoutActivationSlow onClose={onClose} />
)}
{publishableKey && error && (
<Banner
tone="danger"
title={t(
"portal.billing.checkout.error.title",
"Couldn't start checkout",
{phase === "checkout" && (
<>
{!publishableKey && (
<Banner
tone="neutral"
title={t(
"portal.billing.checkout.notConfigured.title",
"Stripe not configured",
)}
>
{t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "}
<code>VITE_STRIPE_PUBLISHABLE_KEY</code>{" "}
{t(
"portal.billing.checkout.notConfigured.bodyAfter",
"in the portal env to enable in-app checkout.",
)}
</Banner>
)}
>
{error}
</Banner>
)}
{publishableKey && loading && !error && (
<div className="portal-billing__skeleton" aria-hidden>
<Skeleton height="3rem" />
<Skeleton height="18rem" />
</div>
)}
{publishableKey && canRender && stripe && clientSecret && (
<EmbeddedCheckoutProvider
stripe={stripe}
options={{ clientSecret, onComplete }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
{publishableKey && error && (
<Banner
tone="danger"
title={t(
"portal.billing.checkout.error.title",
"Couldn't start checkout",
)}
>
{error}
</Banner>
)}
{publishableKey && loading && !error && (
<div className="portal-billing__skeleton" aria-hidden>
<Skeleton height="3rem" />
<Skeleton height="18rem" />
</div>
)}
{publishableKey && canRender && stripe && clientSecret && (
<EmbeddedCheckoutProvider
stripe={stripe}
options={{ clientSecret, onComplete: handleStripeComplete }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)}
</>
)}
</Modal>
);
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Banner, Button } from "@app/ui";
import { meterState } from "@app/billing";
import type { Wallet } from "@portal/api/billing";
import type { LocalUsage } from "@portal/api/link";
import { useStripePortal } from "@portal/hooks/useStripePortal";
import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard";
import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard";
@@ -13,6 +14,8 @@ import { InvoicesList } from "@portal/components/billing/InvoicesList";
interface Props {
wallet: Wallet;
/** Instance-local usage not yet synced to SaaS; folded into the PDFs-processed card. */
unsynced?: LocalUsage | null;
onWalletChange?: () => void;
}
@@ -30,7 +33,11 @@ interface Props {
* page-header "Manage Payment" action and the payment card's "Update" button
* deep-link there via {@link useStripePortal}.
*/
export function SubscribedPlanView({ wallet, onWalletChange }: Props) {
export function SubscribedPlanView({
wallet,
unsynced,
onWalletChange,
}: Props) {
const { t } = useTranslation();
const [adjusting, setAdjusting] = useState(false);
const portal = useStripePortal(wallet);
@@ -93,7 +100,7 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) {
<FreePdfEditorsCard />
<PdfsProcessedCard wallet={wallet} />
<PdfsProcessedCard wallet={wallet} unsynced={unsynced} />
<div className="portal-billing__spend-row">
<SpendThisMonthCard wallet={wallet} />
@@ -3,10 +3,13 @@ import { useTranslation } from "react-i18next";
import { Card } from "@app/ui";
import { formatMinor, MeterBar, meterState } from "@app/billing";
import type { Wallet } from "@portal/api/billing";
import type { LocalUsage } from "@portal/api/link";
interface Props {
/** A linked-free wallet. */
wallet: Wallet;
/** Instance-local usage not yet synced to SaaS; folded into "used" so the trial meter reflects work since the last sync. */
unsynced?: LocalUsage | null;
/** Optional top-right action (e.g. "Switch on the Processor"). */
action?: ReactNode;
}
@@ -16,10 +19,18 @@ interface Props {
* grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
* cloud plan page). The subscribed spend-vs-cap meter is a separate surface
* ({@code SpendLimitCard}); this card is only the free face.
*
* <p>Locally-accrued usage SaaS hasn't billed yet ({@code unsynced}) is folded
* into the used figure + remaining count so the trial depletes in step with the
* gate — which now also blocks against the pending local delta — instead of only
* moving after a daily sync.
*/
export function WalletMeter({ wallet, action }: Props) {
export function WalletMeter({ wallet, unsynced, action }: Props) {
const { t } = useTranslation();
const { state, pct } = meterState(wallet.billableUsed, wallet.freeAllowance);
const pending = unsynced?.totalUnsyncedUnits ?? 0;
const used = wallet.billableUsed + pending;
const remaining = Math.max(0, wallet.freeRemaining - pending);
const { state, pct } = meterState(used, wallet.freeAllowance);
const rate =
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
? wallet.pricePerDocMinor
@@ -65,7 +76,7 @@ export function WalletMeter({ wallet, action }: Props) {
<MeterBar
state={state}
pct={pct}
figure={wallet.billableUsed.toLocaleString()}
figure={used.toLocaleString()}
capSuffix={t(
"portal.billing.walletMeter.capSuffix",
"of {{allowance}} free PDFs used",
@@ -78,8 +89,8 @@ export function WalletMeter({ wallet, action }: Props) {
"portal.billing.walletMeter.statusLabel",
"{{remaining}} left",
{
count: wallet.freeRemaining,
remaining: wallet.freeRemaining.toLocaleString(),
count: remaining,
remaining: remaining.toLocaleString(),
},
)}
/>
@@ -122,6 +122,47 @@
gap: 0.75rem;
}
/* Widen the checkout modal past the ~1000px iframe threshold where Stripe
Embedded Checkout flips from its single-column ("mobile") layout to the
two-column desktop one — matching the SaaS Plan page's UpgradeModal (1100px
cap → ~1056px iframe). Two-class selector so it beats .sui-modal--xl's
max-width regardless of stylesheet order. width:100% still shrinks it on
narrow viewports, where Stripe falls back to single column on its own. */
.sui-modal.portal-billing__checkout-modal {
max-width: 1100px;
}
/* Post-checkout activation state, shown inside the checkout modal while the
subscription webhook lands (and the "almost there" fallback if it lags). */
.portal-billing__checkout-finalizing {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
}
.portal-billing__checkout-status-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-1);
margin: 0.25rem 0 0;
}
.portal-billing__checkout-status-body {
font-size: 0.9375rem;
color: var(--color-text-2);
margin: 0;
max-width: 32rem;
}
.portal-billing__checkout-status-hint {
font-size: 0.8125rem;
color: var(--color-text-3);
margin: 0;
}
.portal-billing__error {
color: var(--color-red, #b91c1c);
font-size: 0.875rem;
@@ -1,6 +1,7 @@
import { http, HttpResponse, delay } from "msw";
import {
getLocalStatus,
getLocalUsage,
linkLocal,
listInstances,
revokeInstance,
@@ -37,6 +38,11 @@ export const linkHandlers = [
return HttpResponse.json(linkLocal(name), { status: 201 });
}),
http.get("/api/v1/account-link/usage", async () => {
await delay(120);
return HttpResponse.json(getLocalUsage());
}),
http.post("/api/v1/account-link/unlink", async () => {
await delay(120);
// Clear local link state, then 204 (no body) to match the real backend.
@@ -44,6 +50,14 @@ export const linkHandlers = [
return new HttpResponse(null, { status: 204 });
}),
// Manual sync trigger — the real backend runs a sync + entitlement refresh and
// returns 204 (or 409 when metering is off). The portal fires it best-effort
// after a checkout completes; the mock just acknowledges.
http.post("/api/v1/account-link/sync-now", async () => {
await delay(120);
return new HttpResponse(null, { status: 204 });
}),
// Team-wide list/revoke are SaaS-direct now (apiClient.saas calls the
// absolute VITE_SAAS_API_URL). Wildcard so the same handlers intercept both
// the relative pattern (legacy / direct-MSW usage) and any absolute SaaS
+34
View File
@@ -36,6 +36,21 @@ export interface LinkStatus {
name: string | null;
}
/**
* Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage).
* The portal adds this on top of the SaaS-synced spend so "current usage"
* includes work done since the last daily sync. Per-category unsynced units for
* the current period; all zero when metering is off or nothing is pending.
*/
export interface LocalUsage {
/** ISO timestamp of the current period start; null when unknown (not yet synced). */
periodStart: string | null;
apiUnsyncedUnits: number;
aiUnsyncedUnits: number;
automationUnsyncedUnits: number;
totalUnsyncedUnits: number;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* SaaS backend — team-wide instance management */
/* ──────────────────────────────────────────────────────────────────────── */
@@ -85,15 +100,34 @@ function seedInstances(): LinkedInstanceRow[] {
];
}
function seedLocalUsage(): LocalUsage {
// A little unsynced usage so the portal's "+ pending sync" combine is visible
// in dev/Storybook.
return {
periodStart: daysAgo(6),
apiUnsyncedUnits: 12,
aiUnsyncedUnits: 3,
automationUnsyncedUnits: 0,
totalUnsyncedUnits: 15,
};
}
let store: LinkedInstanceRow[] = seedInstances();
let nextId = 1004;
let localStatus: LinkStatus = { linked: false, name: null };
let localUsage: LocalUsage = seedLocalUsage();
/** Resets the mock store + local link status to seed state (Storybook / tests). */
export function resetLinkStore(): void {
store = seedInstances();
nextId = 1004;
localStatus = { linked: false, name: null };
localUsage = seedLocalUsage();
}
/** Current instance-local unsynced usage (GET /api/v1/account-link/usage). */
export function getLocalUsage(): LocalUsage {
return { ...localUsage };
}
/** Current local link status for this instance. */
+52 -34
View File
@@ -3,7 +3,16 @@ import { useTranslation } from "react-i18next";
import { Banner, Button, Skeleton } from "@app/ui";
import { useLink } from "@portal/contexts/LinkContext";
import { useUI } from "@portal/contexts/UIContext";
import { fetchWallet, type Wallet } from "@portal/api/billing";
import {
fetchWallet,
refreshWalletCache,
type Wallet,
} from "@portal/api/billing";
import {
fetchLocalUsage,
triggerLocalSync,
type LocalUsage,
} from "@portal/api/link";
import { useStripePortal } from "@portal/hooks/useStripePortal";
import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt";
import { FreePlanView } from "@portal/components/billing/FreePlanView";
@@ -35,14 +44,14 @@ export function Usage() {
const { isLinked, setLinkState, saasSessionNonce } = useLink();
const { openLinkModal } = useUI();
const [wallet, setWallet] = useState<Wallet | null>(null);
// Locally-accrued usage SaaS hasn't billed yet; added to the synced figure so
// "current usage" reflects work since the last daily sync. Best-effort.
const [localUsage, setLocalUsage] = useState<LocalUsage | null>(null);
const [loading, setLoading] = useState<boolean>(isLinked);
const [error, setError] = useState<string | null>(null);
// The instance is linked but the browser's SaaS session has lapsed — needs a
// re-sign-in, NOT a re-link.
const [needsReauth, setNeedsReauth] = useState(false);
// Briefly polling the wallet after a successful checkout until the webhook flips
// it to subscribed.
const [finalizing, setFinalizing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
// Stripe customer portal — the subscribed header's "Manage Payment" action.
const portal = useStripePortal(wallet);
@@ -60,6 +69,7 @@ export function Usage() {
// link prompt; no SaaS call needed.
if (!isLinked) {
setWallet(null);
setLocalUsage(null);
setLoading(false);
setError(null);
setNeedsReauth(false);
@@ -69,6 +79,15 @@ export function Usage() {
setLoading(true);
setError(null);
setNeedsReauth(false);
// Independent of the wallet load — a local-usage failure must not break the
// page; it just means no unsynced delta is shown.
fetchLocalUsage()
.then((u) => {
if (!cancelled) setLocalUsage(u);
})
.catch(() => {
if (!cancelled) setLocalUsage(null);
});
fetchWallet()
.then((w) => {
if (cancelled) return;
@@ -112,31 +131,37 @@ export function Usage() {
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const confirmSubscription = useCallback(async () => {
const confirmSubscription = useCallback(async (): Promise<boolean> => {
// Stripe's onComplete fires before the subscription webhook lands, so poll the
// wallet briefly until it flips to subscribed rather than dropping the
// just-paid admin back on the free CTA.
setFinalizing(true);
for (let i = 0; i < 10; i++) {
// wallet until it flips to subscribed. Drop the server cache before each read
// so we see the webhook the moment it lands rather than after the ~30s TTL.
// ~60s of attempts — longer than the observed webhook + sync-engine latency —
// so a slightly slow activation still completes inside the (open) checkout
// modal instead of falling back to a manual refresh. Resolves true once
// subscribed so the modal can close itself in.
for (let i = 0; i < 30; i++) {
try {
await refreshWalletCache().catch(() => {});
const w = await fetchWallet();
if (!mounted.current) return;
if (!mounted.current) return false;
if (w.status === "subscribed") {
setWallet(w);
setLinkState("linked-subscribed");
setFinalizing(false);
return;
// Nudge the local instance to refresh its gate now so billable work
// unblocks immediately rather than on its next poll. Fire-and-forget.
triggerLocalSync().catch(() => {});
return true;
}
} catch {
// Transient read failure — keep polling.
}
await new Promise((r) => setTimeout(r, 2000));
if (!mounted.current) return;
if (!mounted.current) return false;
}
// Webhook still hasn't landed after ~20s: stop blocking and refresh. The page
// self-heals on the next load once provisioning completes.
setFinalizing(false);
// Webhook still hasn't landed: re-fetch once more and report back so the modal
// shows its "almost there" notice rather than the page silently self-healing.
setRefreshKey((k) => k + 1);
return false;
}, [setLinkState]);
return (
@@ -177,21 +202,6 @@ export function Usage() {
</div>
)}
{isLinked && finalizing && (
<Banner
tone="info"
title={t(
"portal.usage.finalizing.title",
"Finalizing your subscription…",
)}
>
{t(
"portal.usage.finalizing.body",
"It can take a few seconds for your subscription to activate. This page updates automatically.",
)}
</Banner>
)}
{isLinked && needsReauth && (
<Banner
tone="warning"
@@ -230,12 +240,20 @@ export function Usage() {
</Banner>
)}
{isLinked && !finalizing && wallet && wallet.status === "free" && (
<FreePlanView wallet={wallet} onSubscribed={confirmSubscription} />
{isLinked && wallet && wallet.status === "free" && (
<FreePlanView
wallet={wallet}
unsynced={localUsage}
onSubscribed={confirmSubscription}
/>
)}
{isLinked && wallet && wallet.status === "subscribed" && (
<SubscribedPlanView wallet={wallet} onWalletChange={refresh} />
<SubscribedPlanView
wallet={wallet}
unsynced={localUsage}
onWalletChange={refresh}
/>
)}
</div>
</div>