diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java index a77f67f8a8..bdd9df10a8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -6,6 +6,7 @@ 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 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"). * - *

Two calls: + *

Calls: * *

* - *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see - * {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS - * endpoint. + *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see + * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS. */ @Slf4j @Service @@ -86,11 +93,9 @@ public int status() { } /** - * Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked - * or invalid. Distinct from a transport/server failure (which returns {@code null} and fails - * open): the cache must BLOCK billable work on this rather than serve a stale entitled - * snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport - * try/catch. + * Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a + * transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on + * this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch. */ public static final class RevokedException extends RuntimeException { private final int status; @@ -142,11 +147,9 @@ public RegisterResult register(String supabaseJwt, String instanceName) throws I } /** - * Revokes this instance's own credential on the SaaS side ({@code POST - * /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is - * allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable - * or rejects the call, so the caller (local unlink) can still clear locally and log the orphan - * row for follow-up. Idempotent on SaaS (already-revoked → still 204). + * Revokes this instance's own credential on the SaaS side, authenticated by that credential. + * Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local + * unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS. */ public boolean revokeSelf(String deviceId, String deviceSecret) { try { @@ -218,6 +221,63 @@ public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret } } + /** + * Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and + * returns the fresh entitlement in the same reply — one round-trip both reports and refreshes. + * SaaS bills the delta against its last-seen cumulative, so resending the same totals is + * idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must + * not advance its last-synced markers so the usage retries next sync. + */ + public InstanceEntitlement reportUsage( + String deviceId, + String deviceSecret, + long syncSeq, + LocalDateTime periodStart, + long apiUnits, + long aiUnits, + long automationUnits) { + HttpResponse response; + try { + ObjectNode root = mapper.createObjectNode(); + root.put("syncSeq", syncSeq); + // Explicit ISO-8601 string so it round-trips regardless of the mapper's time config. + root.put("periodStart", periodStart.toString()); + ObjectNode units = root.putObject("cumulativeUnits"); + units.put("api", apiUnits); + units.put("ai", aiUnits); + units.put("automation", automationUnits); + String body = mapper.writeValueAsString(root); + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/instance/sync")) + .header(HEADER_DEVICE_ID, deviceId) + .header(HEADER_DEVICE_SECRET, deviceSecret) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .timeout(timeout()) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + response = send(request); + } catch (Exception e) { + log.debug("Usage sync failed: {}", e.getMessage()); + return null; + } + int status = response.statusCode(); + if (status == 401 || status == 403) { + throw new RevokedException(status); + } + if (status / 100 != 2) { + log.debug("Usage sync returned HTTP {}", status); + return null; + } + try { + return parseEntitlement(response.body()); + } catch (IOException e) { + log.debug("Usage sync parse failed: {}", e.getMessage()); + return null; + } + } + private InstanceEntitlement parseEntitlement(String body) throws IOException { JsonNode root = mapper.readTree(body); boolean subscribed = root.path("subscribed").asBoolean(false); @@ -226,7 +286,45 @@ private InstanceEntitlement parseEntitlement(String body) throws IOException { Long periodCap = root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null; EntitlementState state = mapState(root.path("state").asText(null)); - return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state); + return new InstanceEntitlement( + subscribed, + freeRemaining, + periodSpend, + periodCap, + state, + parseUnitCalcPolicy(root), + parseDateTime(root, "periodStart"), + parseDateTime(root, "periodEnd")); + } + + /** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */ + private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) { + if (!root.hasNonNull("unitCalcPolicy")) { + return null; + } + JsonNode node = root.get("unitCalcPolicy"); + try { + return new UnitCalcPolicy( + node.path("docPagesPerUnit").asInt(), + node.path("docBytesPerUnit").asLong(), + node.path("minChargeUnits").asInt(), + node.path("fileUnitCap").asInt()); + } catch (RuntimeException e) { + // Malformed policy → degrade to "none" rather than fail the whole entitlement parse. + return null; + } + } + + /** ISO date-time field → LocalDateTime; null if absent or unparseable. */ + private static LocalDateTime parseDateTime(JsonNode root, String field) { + if (!root.hasNonNull(field)) { + return null; + } + try { + return LocalDateTime.parse(root.get(field).asText(null)); + } catch (RuntimeException e) { + return null; + } } /** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java index 1d1a8aa77d..52af366df4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -2,6 +2,7 @@ import java.io.IOException; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -23,7 +24,9 @@ *

The portal (served from this same origin, admin authenticated by the existing self-hosted * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS * backend, which mints + returns a device credential we store locally. {@code GET /status} backs - * the portal's link card. + * the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds + * to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now" + * / test aid). * *

Admin-only, {@code @Profile("!saas")}, gated behind {@code * stirling.billing.account-link.enabled} — off → bean absent → 404. @@ -38,9 +41,17 @@ public class AccountLinkController { private final AccountLinkService service; + private final LocalUsageService localUsageService; + // Present only when metering is on (its own flag); absent → /sync-now reports 409. + private final ObjectProvider syncServiceProvider; - public AccountLinkController(AccountLinkService service) { + public AccountLinkController( + AccountLinkService service, + LocalUsageService localUsageService, + ObjectProvider syncServiceProvider) { this.service = service; + this.localUsageService = localUsageService; + this.syncServiceProvider = syncServiceProvider; } /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ @@ -85,4 +96,29 @@ public ResponseEntity unlink() { service.unlink(); return ResponseEntity.noContent().build(); } + + /** + * Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend + * so "current usage" includes work done since the last daily sync. + */ + @GetMapping("/usage") + public ResponseEntity usage() { + return ResponseEntity.ok(localUsageService.currentPeriodUnsynced()); + } + + /** + * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin + * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent: + * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run; + * {@code 409} when metering is off (the sync bean is absent). + */ + @PostMapping("/sync-now") + public ResponseEntity syncNow() { + UsageSyncService sync = syncServiceProvider.getIfAvailable(); + if (sync == null) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + sync.syncNow(); + return ResponseEntity.noContent().build(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java index c12e56f06d..6d1f1fb151 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.accountlink; +import java.time.Duration; + import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -36,4 +38,39 @@ public class AccountLinkProperties { /** Connect/read timeout for the outbound SaaS calls. */ private int requestTimeoutSeconds = 10; + + /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */ + private final Metering metering = new Metering(); + + /** + * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be + * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap + * enforcement. Both default off; metering requires the master flag too. This is the production + * safety key — flipping it on is what actually bills linked instances. + */ + @Getter + @Setter + public static class Metering { + + /** Turns on usage metering, the daily sync, and cap enforcement. Default off. */ + private boolean enabled = false; + + /** + * How often the instance syncs usage + refreshes entitlement (matches the licence sync). + */ + private int syncIntervalHours = 24; + + /** + * Block billable work after this many days with no successful sync (fail-open → closed). + */ + private int graceDays = 3; + + /** + * Dedup window for identical input sets. A re-run of the same inputs within this window is + * treated as workflow chaining and not re-charged; the same inputs run again after it are + * billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op + * costs the same on the instance and in the cloud. + */ + private Duration workflowWindow = Duration.ofMinutes(5); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java new file mode 100644 index 0000000000..fbac6a8603 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java @@ -0,0 +1,46 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A"). + * + *

{@link #lastSyncSeq} is reserved (incremented + persisted) before each report so it + * is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it, + * so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last + * sync SaaS accepted and drives the fail-open→closed grace window. + * + *

Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync. + */ +@Entity +@Table(name = "account_link_sync_state") +@Getter +@Setter +@NoArgsConstructor +public class AccountLinkSyncState { + + /** One instance links to one team → one bookkeeping row. */ + public static final long SINGLETON_ID = 1L; + + @Id private Long id; + + // columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres. + @Column( + name = "last_sync_seq", + nullable = false, + columnDefinition = "bigint not null default 0") + private long lastSyncSeq; + + /** Null until the first sync SaaS accepts. */ + @Column(name = "last_success_at") + private LocalDateTime lastSuccessAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java new file mode 100644 index 0000000000..15b5e3842d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */ +public interface AccountLinkSyncStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java index 136e4c3214..476fbd111a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java @@ -3,14 +3,26 @@ import jakarta.servlet.http.HttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.BillingCategoryClassifier; /** - * Classifies a request as billable (AI / automation) or free (a manual tool). + * Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only + * HTTP-level signals (no dependency on the saas module): * - *

Mirrors the saas billing categorisation at a coarse level, without depending on the saas - * module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation - * marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy - * sub-steps). Everything else — interactive manual PDF tools — is always free. + *

+ * + *

Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared + * {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the + * saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the + * caller (resolved from the security context), so this class stays free of any security-type + * dependency. */ public final class BillableOperationClassifier { @@ -18,16 +30,22 @@ public final class BillableOperationClassifier { private BillableOperationClassifier() {} - public static boolean isBillable(HttpServletRequest request) { - if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) { - return true; - } + /** + * @param apiKey whether the request authenticated via an API key (an {@code + * ApiKeyAuthenticationToken} principal), resolved by the caller from the security context. + */ + public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) { + boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null; + return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey); + } + + private static boolean isAiSurface(HttpServletRequest request) { String uri = request.getRequestURI(); if (uri == null) { return false; } // Prefix-match the AI surface (not a loose substring contains), stripping a deployment - // context path so //api/v1/ai/** still classifies as billable. + // context path so //api/v1/ai/** still classifies as AI. String ctx = request.getContextPath(); String path = ctx != null && !ctx.isEmpty() && uri.startsWith(ctx) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java index 63818c1f98..898fb54b3a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java @@ -12,18 +12,13 @@ import lombok.extern.slf4j.Slf4j; /** - * Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on - * every billable request. Single-slot (one instance = one linked team), TTL-based. + * Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable + * request. Single-slot (one instance = one linked team), TTL-based. * - *

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

But an AUTHORITATIVE deny (revoked/invalid credential → {@link - * AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a - * {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately - * rather than serving a stale entitled snapshot. + *

A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has + * and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An + * authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is + * replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately. */ @Slf4j @Service @@ -63,9 +58,8 @@ public EntitlementCache( * not linked or the SaaS side is unreachable and we have no prior snapshot. */ public Optional current() { - // Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS - // call) while concurrent callers serve the last snapshot — no thundering herd of - // synchronous round-trips on the billable hot path. Safe because the gate fails open. + // Single-flight: when stale, exactly one thread refreshes while concurrent callers serve + // the last snapshot — no thundering herd of round-trips on the billable hot path. if (isStale(snapshot) && refreshing.compareAndSet(false, true)) { try { refresh(); @@ -77,16 +71,15 @@ public Optional current() { } private boolean isStale(Snapshot snap) { - // fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed - // fetch backs off for a full TTL instead of every billable request re-triggering a - // blocking round-trip against a dead/slow SaaS endpoint. + // fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch + // backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS. return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0; } /** - * Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but - * still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny - * (revoked credential) replaces it with a blocked snapshot so the gate stops billable work. + * Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the + * attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a + * blocked snapshot. */ void refresh() { Optional cred = credentialStore.get(); @@ -101,15 +94,15 @@ void refresh() { if (fresh != null) { snapshot = new Snapshot(fresh, Instant.now()); } else { - // Unreachable / server error: keep the last known entitlement (may be null) but - // stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime. + // Unreachable / server error: keep the last known entitlement but stamp the attempt + // so we don't hammer SaaS; the gate fails open meanwhile. log.debug( "Entitlement refresh failed; reusing last known snapshot, backing off a TTL"); snapshot = new Snapshot(snapshot.entitlement(), Instant.now()); } } catch (AccountLinkClient.RevokedException e) { - // Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately - // rather than serving the stale entitled snapshot until the next unlink. + // Authoritative deny — block immediately rather than serving the stale entitled + // snapshot. log.info( "Entitlement denied (HTTP {}); blocking billable work for the revoked credential", e.status()); @@ -121,4 +114,14 @@ void refresh() { public void invalidate() { snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH); } + + /** + * Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh + * one), saving a redundant fetch. No-op on null. + */ + public void accept(InstanceEntitlement fresh) { + if (fresh != null) { + snapshot = new Snapshot(fresh, Instant.now()); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java index 677183278b..87b4ddcde9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java @@ -16,6 +16,11 @@ public enum Reason { ENTITLED, /** Entitlement source unreachable — fail open, allow. */ FAIL_OPEN, + /** + * Linked + metering, but SaaS has been unreachable past the grace window — block (the + * fail-open backstop expired) so unbounded free/unbilled billable work can't continue. + */ + GRACE_EXPIRED, /** Not linked — block billable work; FE should prompt to link. */ NOT_LINKED, /** Linked but over the limit / no subscription — block billable work. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java index 6445d886e9..8760239957 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java @@ -1,19 +1,53 @@ package stirling.software.proprietary.accountlink; +import java.time.LocalDateTime; + +import stirling.software.proprietary.billing.UnitCalcPolicy; + /** - * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response — - * just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no - * saas types. + * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response. + * Mirrors the saas {@code EntitlementResponse} shape but carries no saas types. + * + *

The first five fields are what the gate enforces against; the trailing three are the + * metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its + * per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the + * revoked sentinel and unit tests that don't exercise metering. * * @param subscribed team has an active subscription * @param freeRemainingUnits remaining free-pool units (>0 means free work is available) * @param periodSpendUnits paid units spent this period * @param periodCapUnits paid cap for the period; {@code null} = uncapped * @param state coarse state classification (see {@link EntitlementState}) + * @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not + * supplied (older SaaS / gate-only sentinel) + * @param periodStart inclusive start of the current billing period; {@code null} if not supplied + * @param periodEnd exclusive end of the current billing period; {@code null} if not supplied */ public record InstanceEntitlement( boolean subscribed, long freeRemainingUnits, long periodSpendUnits, Long periodCapUnits, - EntitlementState state) {} + EntitlementState state, + UnitCalcPolicy unitCalcPolicy, + LocalDateTime periodStart, + LocalDateTime periodEnd) { + + /** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */ + public InstanceEntitlement( + boolean subscribed, + long freeRemainingUnits, + long periodSpendUnits, + Long periodCapUnits, + EntitlementState state) { + this( + subscribed, + freeRemainingUnits, + periodSpendUnits, + periodCapUnits, + state, + null, + null, + null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java index c975bad055..018684b73f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.accountlink; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -15,14 +16,17 @@ *

  • Flag off → always allow (feature inert). *
  • Manual tool → always allow (manual tools are free, never metered). *
  • Billable + not linked → block with {@code NOT_LINKED} ("link to activate"). - *
  • Billable + linked + entitlement unknown (unreachable) → fail open, allow. + *
  • Billable + linked + entitlement unknown (unreachable) → fail open, allow — unless + * metering is on and SaaS has been unreachable past the grace window, then block with {@code + * GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever. *
  • Billable + linked + entitled → allow. *
  • Billable + linked + credential revoked → block with {@code REVOKED}. *
  • Billable + linked + over limit → block with {@code OVER_LIMIT}. * * - *

    The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the - * live flag / linked-state / entitlement. This is the unit-tested core. + *

    The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live + * flag / linked-state / entitlement and computes whether the grace window has expired. This is the + * unit-tested core. */ @Service @Profile("!saas") @@ -32,14 +36,20 @@ public class InstanceEntitlementGate { private final AccountLinkProperties properties; private final DeviceCredentialStore credentialStore; private final EntitlementCache entitlementCache; + private final AccountLinkSyncStateRepository syncStateRepository; + private final LocalUsageService localUsageService; public InstanceEntitlementGate( AccountLinkProperties properties, DeviceCredentialStore credentialStore, - EntitlementCache entitlementCache) { + EntitlementCache entitlementCache, + AccountLinkSyncStateRepository syncStateRepository, + LocalUsageService localUsageService) { this.properties = properties; this.credentialStore = credentialStore; this.entitlementCache = entitlementCache; + this.syncStateRepository = syncStateRepository; + this.localUsageService = localUsageService; } /** Evaluates the gate for a request, resolving live state from the store + cache. */ @@ -53,18 +63,39 @@ public GateDecision evaluate(boolean billable) { boolean linked = credentialStore.isLinked(); Optional entitlement = linked ? entitlementCache.current() : Optional.empty(); - return decide(true, true, linked, entitlement); + boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired(); + // Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped + // subscription) — by local usage not yet synced, so the gate stops in real time instead of + // overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0. + long pendingUnsynced = + entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false) + ? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits() + : 0L; + return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced); + } + + /** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */ + private static boolean depletesCeiling(InstanceEntitlement e) { + return !e.subscribed() || e.periodCapUnits() != null; } /** * Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown" - * (unreachable): when linked, that fails open. + * (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace + * window elapsed with no authoritative contact), in which case it blocks. + * + * @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the + * free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate + * stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement + * cases, where it has no effect). */ public static GateDecision decide( boolean flagEnabled, boolean billable, boolean linked, - Optional entitlement) { + Optional entitlement, + boolean graceExpired, + long pendingUnsyncedUnits) { if (!flagEnabled) { return GateDecision.allow(GateDecision.Reason.FLAG_OFF); } @@ -75,30 +106,69 @@ public static GateDecision decide( return GateDecision.block(GateDecision.Reason.NOT_LINKED); } if (entitlement.isEmpty()) { - // Linked but entitlement source unreachable — never hard-block billable work on our - // inability to reach billing. - return GateDecision.allow(GateDecision.Reason.FAIL_OPEN); + // Linked but entitlement unreachable: fail open, unless the grace window has expired + // (so + // the fail-open can't grant unbounded unbilled work forever). + return graceExpired + ? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED) + : GateDecision.allow(GateDecision.Reason.FAIL_OPEN); } InstanceEntitlement e = entitlement.get(); if (e.state() == EntitlementState.REVOKED) { // Credential revoked/invalid (authoritative deny) — block, distinct from over-limit. return GateDecision.block(GateDecision.Reason.REVOKED); } - return entitled(e) + return entitled(e, pendingUnsyncedUnits) ? GateDecision.allow(GateDecision.Reason.ENTITLED) : GateDecision.block(GateDecision.Reason.OVER_LIMIT); } + /** + * True when metering is on and it's been {@code graceDays} since the last authoritative contact + * (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off + * disables the backstop. + */ + private boolean isGraceExpired() { + AccountLinkProperties.Metering metering = properties.getMetering(); + if (!metering.isEnabled() || metering.getGraceDays() <= 0) { + return false; + } + LocalDateTime reference = lastAuthoritativeContact(); + if (reference == null) { + return false; // can't determine elapsed time → fail open + } + return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now()); + } + + private LocalDateTime lastAuthoritativeContact() { + LocalDateTime lastSuccess = + syncStateRepository + .findById(AccountLinkSyncState.SINGLETON_ID) + .map(AccountLinkSyncState::getLastSuccessAt) + .orElse(null); + if (lastSuccess != null) { + return lastSuccess; + } + return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null); + } + /** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */ - private static boolean entitled(InstanceEntitlement e) { + private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) { if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) { return false; } if (e.subscribed()) { - // Subscribed: allowed unless a period cap is set and exceeded. - return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits(); + if (e.periodCapUnits() == null) { + return true; // uncapped subscription + } + // Project the cap the way the grant is projected: synced paid spend plus the paid part + // of local usage not yet synced (free grant is consumed first, so only the excess + // bills) — stops at the cap in real time instead of overshooting until the next sync. + long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits()); + return e.periodSpendUnits() + pendingPaid < e.periodCapUnits(); } - // Unsubscribed: only the free pool covers billable work. - return e.freeRemainingUnits() > 0; + // Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local + // usage not yet synced — deplete by the pending delta so we stop at the grant in real time. + return e.freeRemainingUnits() - pendingUnsyncedUnits > 0; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 8597813a85..285943ee49 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -1,27 +1,51 @@ package stirling.software.proprietary.accountlink; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.util.WebUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.ContentHasher; +import stirling.software.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; + /** - * Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and - * blocks it when the instance is unlinked or over its limit; manual tools pass straight through. + * Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API / + * AI / automation) work when the instance is unlinked or over its limit; manual tools pass through. + * {@code afterCompletion} meters a successful billable op into the per-period cumulative counter. * - *

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

    Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link - * AccountLinkWebMvcConfig} never registers it, so there is no per-request cost. + *

    Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate" + * prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind + * {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link + * UsageMeterService} bean is absent and nothing accrues, while the gate still works. */ @Slf4j @Component @@ -29,10 +53,23 @@ @ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class InstanceEntitlementInterceptor implements HandlerInterceptor { + private static final String ATTR_CATEGORY = + InstanceEntitlementInterceptor.class.getName() + ".category"; + private final InstanceEntitlementGate gate; + private final EntitlementCache entitlementCache; + private final ObjectProvider meterProvider; + private final TempFileManager tempFileManager; - public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) { + public InstanceEntitlementInterceptor( + InstanceEntitlementGate gate, + EntitlementCache entitlementCache, + ObjectProvider meterProvider, + TempFileManager tempFileManager) { this.gate = gate; + this.entitlementCache = entitlementCache; + this.meterProvider = meterProvider; + this.tempFileManager = tempFileManager; } @Override @@ -41,7 +78,13 @@ public boolean preHandle( throws Exception { GateDecision decision; try { - decision = gate.evaluate(BillableOperationClassifier.isBillable(request)); + // API-key tool calls are billable (category API); stash the category for the meter. + boolean apiKey = + SecurityContextHolder.getContext().getAuthentication() + instanceof ApiKeyAuthenticationToken; + BillingCategory category = BillableOperationClassifier.categorize(request, apiKey); + request.setAttribute(ATTR_CATEGORY, category); + decision = gate.evaluate(category != BillingCategory.BYPASSED); } catch (RuntimeException e) { // Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never // turn into a hard block on billable work. @@ -62,4 +105,139 @@ public boolean preHandle( + "\"}"); return false; } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + // Meter successful billable ops only. + if (ex != null || response.getStatus() >= 400) { + return; + } + UsageMeterService meter = meterProvider.getIfAvailable(); + if (meter == null) { + return; // metering switch off + } + if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category) + || category == BillingCategory.BYPASSED) { + return; + } + try { + InstanceEntitlement ent = entitlementCache.current().orElse(null); + if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) { + // Not yet synced (no policy/period) — can't compute units; skip until next sync. + return; + } + meterRequest(request, category, ent, meter); + } catch (RuntimeException e) { + // Metering must never affect the response that already completed. + log.debug("Usage metering failed for {}", request.getRequestURI(), e); + } + } + + /** + * Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance + * is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy + * but small PDF must be page-counted or it under-bills. A fileless op has no input identity — + * null signature (no dedup), billed the 1-unit floor each time. + */ + private void meterRequest( + HttpServletRequest request, + BillingCategory category, + InstanceEntitlement ent, + UsageMeterService meter) { + UnitCalcPolicy policy = ent.unitCalcPolicy(); + MultipartHttpServletRequest mreq = + WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); + if (mreq == null) { + long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy); + meter.accrue(ent.periodStart(), category, fileless, null); + return; + } + List temps = new ArrayList<>(); + try { + List sizes = new ArrayList<>(); + List hashes = new ArrayList<>(); + int fileCount = 0; + for (List files : mreq.getMultiFileMap().values()) { + for (MultipartFile f : files) { + fileCount++; + try { + TempFile temp = tempFileManager.createManagedTempFile(".bin"); + temps.add(temp); + // Hash in the same pass that writes the temp file — one read of the upload, + // not a second full read just to fingerprint it. + MessageDigest digest = ContentHasher.newSha256(); + try (InputStream in = f.getInputStream(); + DigestOutputStream out = + new DigestOutputStream( + Files.newOutputStream(temp.getPath()), digest)) { + in.transferTo(out); + } + sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize())); + hashes.add(ContentHasher.toHex(digest.digest())); + } catch (IOException | RuntimeException perFile) { + // Couldn't materialise/hash this input — bill on bytes only and, by leaving + // it out of `hashes`, drop dedup for the whole op rather than risk a + // mismatch. + log.debug( + "Metering materialise/hash failed for {}; bytes-only", + f.getOriginalFilename()); + sizes.add(new FileSize(0, f.getSize())); + } + } + } + long units = + sizes.isEmpty() + ? DocumentUnitCalculator.unitsForFile(0, 0, policy) + : DocumentUnitCalculator.unitsForGroup(sizes, policy); + // Only dedup when every input hashed; a partial signature could collide with a + // different input set, so fall back to no-dedup (bill it) if any file failed. + String opSignature = + fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null; + meter.accrue(ent.periodStart(), category, units, opSignature); + } finally { + for (TempFile temp : temps) { + try { + temp.close(); + } catch (RuntimeException cleanup) { + log.debug("Temp file cleanup failed: {}", cleanup.getMessage()); + } + } + } + } + + /** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */ + private static int pageCount(Path path, MultipartFile file) { + if (!isPdf(file)) { + return 0; + } + try (PdfDocument doc = PdfDocument.open(path)) { + return doc.pageCount(); + } catch (RuntimeException e) { + // Malformed / encrypted → byte axis only, matching the SaaS classifier. + log.debug( + "Page count unavailable for {}; metering on bytes only", + file.getOriginalFilename()); + return 0; + } + } + + /** Order-independent signature of the input set: sorted per-file hashes, hashed together. */ + private static String opSignature(List hashes) { + List sorted = new ArrayList<>(hashes); + Collections.sort(sorted); + return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8)); + } + + private static boolean isPdf(MultipartFile file) { + String contentType = file.getContentType(); + if (contentType != null && contentType.toLowerCase().contains("pdf")) { + return true; + } + String name = file.getOriginalFilename(); + return name != null && name.toLowerCase().endsWith(".pdf"); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java new file mode 100644 index 0000000000..58d365fe28 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.EnumMap; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal + * adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync. + * + *

    Unsynced per category = {@code cumulativeUnits − lastSyncedUnits} (floored at 0), scoped to + * the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown + * or metering is off. + */ +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class LocalUsageService { + + private final UsageCounterRepository counters; + private final EntitlementCache entitlementCache; + + public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) { + this.counters = counters; + this.entitlementCache = entitlementCache; + } + + /** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */ + public record LocalUsage( + LocalDateTime periodStart, + long apiUnsyncedUnits, + long aiUnsyncedUnits, + long automationUnsyncedUnits, + long totalUnsyncedUnits) {} + + public LocalUsage currentPeriodUnsynced() { + LocalDateTime period = + entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null); + if (period == null) { + return new LocalUsage(null, 0, 0, 0, 0); + } + EnumMap unsynced = new EnumMap<>(BillingCategory.class); + for (UsageCounter c : counters.findByPeriodStart(period)) { + BillingCategory cat = c.billingCategory(); + if (cat != null && cat != BillingCategory.BYPASSED) { + unsynced.merge(cat, c.unsyncedUnits(), Long::sum); + } + } + long api = unsynced.getOrDefault(BillingCategory.API, 0L); + long ai = unsynced.getOrDefault(BillingCategory.AI, 0L); + long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L); + return new LocalUsage(period, api, ai, automation, api + ai + automation); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java new file mode 100644 index 0000000000..1ed49d6a8b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * The last time the instance metered a given input set this period — the local equivalent of the + * cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling workflow + * window: an identical input set re-submitted within the window (see {@link + * AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the + * same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job + * window so the same operation costs the same on the instance and in the cloud. + * + *

    {@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud + * artifact touches its job). One row per {@code (period, signature)}; the unique constraint also + * makes the first-sighting insert an atomic claim under concurrency. + * + *

    Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter. + */ +@Entity +@Table( + name = "account_link_metered_signature", + uniqueConstraints = + @UniqueConstraint( + name = "uk_account_link_metered_signature", + columnNames = {"period_start", "signature"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MeteredInputSignature { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */ + @Column(name = "signature", nullable = false, length = 64) + private String signature; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + /** + * When this input set was last metered — the anchor the workflow-window dedup compares against. + */ + @Column(name = "last_metered_at") + private LocalDateTime lastMeteredAt; + + public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) { + this.periodStart = periodStart; + this.signature = signature; + this.createdAt = at; + this.lastMeteredAt = at; + } + + /** Slides the window forward — the input set was seen again. */ + public void touch(LocalDateTime at) { + this.lastMeteredAt = at; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java new file mode 100644 index 0000000000..863f503f61 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */ +public interface MeteredInputSignatureRepository + extends JpaRepository { + + /** The existing row for a seen input set, so the meter can apply the workflow-window check. */ + Optional findByPeriodStartAndSignature( + LocalDateTime periodStart, String signature); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java new file mode 100644 index 0000000000..b90af07958 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -0,0 +1,105 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A". + * Each successful billable op increments its row; the daily sync reports the cumulative totals and + * SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills + * nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, + * category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it. + */ +@Entity +@Table( + name = "account_link_usage_counter", + uniqueConstraints = + @UniqueConstraint( + name = "uk_usage_counter_period_category", + columnNames = {"period_start", "category"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UsageCounter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Inclusive start of the billing period this counter belongs to (from the entitlement sync). + */ + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */ + @Column(name = "category", nullable = false, length = 32) + private String category; + + /** Running total of metered units in this period+category. */ + @Column(name = "cumulative_units", nullable = false) + private long cumulativeUnits; + + /** + * {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported + * usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default + * keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already + * populated (NOT NULL with no default would fail the ALTER). + */ + @Column( + name = "last_synced_units", + nullable = false, + columnDefinition = "bigint not null default 0") + private long lastSyncedUnits; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** Fresh-accrual row: nothing synced yet. */ + public UsageCounter( + LocalDateTime periodStart, + String category, + long cumulativeUnits, + LocalDateTime updatedAt) { + this(periodStart, category, cumulativeUnits, 0L, updatedAt); + } + + public UsageCounter( + LocalDateTime periodStart, + String category, + long cumulativeUnits, + long lastSyncedUnits, + LocalDateTime updatedAt) { + this.periodStart = periodStart; + this.category = category; + this.cumulativeUnits = cumulativeUnits; + this.lastSyncedUnits = lastSyncedUnits; + this.updatedAt = updatedAt; + } + + /** This row's category as the enum, or {@code null} for an unrecognised stored value. */ + public BillingCategory billingCategory() { + try { + return BillingCategory.valueOf(category); + } catch (IllegalArgumentException unknown) { + return null; + } + } + + /** Units accrued but not yet accepted by SaaS (floored at 0). */ + public long unsyncedUnits() { + return Math.max(0, cumulativeUnits - lastSyncedUnits); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java new file mode 100644 index 0000000000..2140775abc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -0,0 +1,57 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */ +public interface UsageCounterRepository extends JpaRepository { + + /** + * Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated + * (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a + * read-modify-write race between concurrent billable requests. + */ + @Modifying + @Transactional + @Query( + "UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta," + + " c.updatedAt = :now" + + " WHERE c.periodStart = :periodStart AND c.category = :category") + int increment( + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category, + @Param("delta") long delta, + @Param("now") LocalDateTime now); + + /** All counters for a period — the daily sync reads these to report cumulative totals. */ + List findByPeriodStart(LocalDateTime periodStart); + + /** + * Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each + * so end-of-period usage isn't stranded when the billing period rolls over between syncs. + */ + @Query( + "SELECT DISTINCT c.periodStart FROM UsageCounter c" + + " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart") + List findPeriodsWithUnsyncedUsage(); + + /** + * Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by + * SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced. + */ + @Modifying + @Transactional + @Query( + "UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits" + + " WHERE c.periodStart = :periodStart AND c.category = :category") + int markSynced( + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category, + @Param("syncedUnits") long syncedUnits); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java new file mode 100644 index 0000000000..6aa93606fb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java @@ -0,0 +1,115 @@ +package stirling.software.proprietary.accountlink; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily + * sync later reports the cumulative totals to SaaS. + * + *

    Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow- + * window} is treated as chaining and not re-charged; the same inputs run again after the window are + * billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the + * instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue} + * is best-effort: callers need not handle persistence errors. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty( + name = "stirling.billing.account-link.metering.enabled", + havingValue = "true") +public class UsageMeterService { + + private final UsageCounterRepository repo; + private final MeteredInputSignatureRepository signatureRepo; + private final Duration workflowWindow; + + public UsageMeterService( + UsageCounterRepository repo, + MeteredInputSignatureRepository signatureRepo, + AccountLinkProperties properties) { + this.repo = repo; + this.signatureRepo = signatureRepo; + this.workflowWindow = properties.getMetering().getWorkflowWindow(); + } + + /** + * Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first + * use), unless {@code opSignature} was already metered this period. No-ops for non-billable + * categories, non-positive units, or a missing period. + */ + public void accrue( + LocalDateTime periodStart, BillingCategory category, long units, String opSignature) { + if (periodStart == null + || category == null + || category == BillingCategory.BYPASSED + || units <= 0) { + return; + } + if (opSignature != null && !shouldCharge(periodStart, opSignature)) { + return; // identical inputs seen within the workflow window — chaining, already billed + } + incrementOrInsert(periodStart, category.name(), units); + } + + /** + * True when this input set should be charged: unseen this period, or last seen outside the + * workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and + * slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge. + */ + private boolean shouldCharge(LocalDateTime periodStart, String opSignature) { + LocalDateTime now = LocalDateTime.now(); + MeteredInputSignature seen = + signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null); + if (seen == null) { + try { + signatureRepo.saveAndFlush( + new MeteredInputSignature(periodStart, opSignature, now)); + return true; // first sighting this period + } catch (DataIntegrityViolationException raced) { + return false; // a concurrent op just claimed it — within window → chaining + } catch (RuntimeException e) { + log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage()); + return true; + } + } + LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now; + boolean withinWindow = last.isAfter(now.minus(workflowWindow)); + try { + seen.touch(now); + signatureRepo.save(seen); + } catch (RuntimeException e) { + log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage()); + } + return !withinWindow; + } + + private void incrementOrInsert(LocalDateTime periodStart, String category, long units) { + LocalDateTime now = LocalDateTime.now(); + try { + if (repo.increment(periodStart, category, units, now) > 0) { + return; + } + try { + repo.saveAndFlush(new UsageCounter(periodStart, category, units, now)); + } catch (DataIntegrityViolationException raceLostInsert) { + // A concurrent request inserted the row first — increment the now-existing row. + repo.increment(periodStart, category, units, now); + } + } catch (RuntimeException e) { + // Metering must never break the request it rode in on; a lost accrual self-heals on the + // next increment and the daily sync reports the cumulative total either way. + log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage()); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java new file mode 100644 index 0000000000..4c4ce2377c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -0,0 +1,189 @@ +package stirling.software.proprietary.accountlink; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumMap; +import java.util.List; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.FixedDelayTask; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category + * usage to SaaS, which bills the delta against its own last-seen totals. + * + *

    Resilience: the sync seq is persisted before the report so it never regresses across + * restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so + * usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All + * periods with unsynced usage are reported so nothing is stranded when the period rolls over + * between syncs. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty( + name = "stirling.billing.account-link.metering.enabled", + havingValue = "true") +public class UsageSyncService implements SchedulingConfigurer { + + // First run waits out startup churn; then every interval. + private static final Duration INITIAL_DELAY = Duration.ofMinutes(5); + + private final UsageCounterRepository counters; + private final AccountLinkSyncStateRepository syncState; + private final DeviceCredentialStore credentialStore; + private final AccountLinkClient client; + private final EntitlementCache entitlementCache; + private final AccountLinkProperties properties; + + public UsageSyncService( + UsageCounterRepository counters, + AccountLinkSyncStateRepository syncState, + DeviceCredentialStore credentialStore, + AccountLinkClient client, + EntitlementCache entitlementCache, + AccountLinkProperties properties) { + this.counters = counters; + this.syncState = syncState; + this.credentialStore = credentialStore; + this.client = client; + this.entitlementCache = entitlementCache; + this.properties = properties; + } + + /** + * Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in + * code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather + * than only on a flags-on run. + */ + @Override + public void configureTasks(ScheduledTaskRegistrar registrar) { + Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours()); + registrar.addFixedDelayTask( + new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY)); + } + + public void scheduledSync() { + try { + syncNow(); + } catch (RuntimeException e) { + log.debug("Scheduled usage sync failed", e); + } + } + + /** + * Reports every period with unsynced usage and refreshes the cached entitlement from the reply. + * Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when + * unlinked or when nothing is pending. + */ + public void syncNow() { + Optional cred = credentialStore.get(); + if (cred.isEmpty()) { + return; // not linked + } + List periods = counters.findPeriodsWithUnsyncedUsage(); + if (periods.isEmpty()) { + // Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement + // change (e.g. the admin just subscribed) that otherwise wouldn't surface until the + // cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now. + entitlementCache.invalidate(); + entitlementCache.current(); + return; + } + InstanceEntitlement latest = null; + try { + for (LocalDateTime period : periods) { + InstanceEntitlement fresh = syncPeriod(cred.get(), period); + if (fresh != null) { + latest = fresh; + } + } + } catch (AccountLinkClient.RevokedException e) { + // Authoritative deny — stop reporting; the entitlement cache blocks billable work on + // its + // own next refresh, so we don't synthesise the blocked state here. + log.info( + "Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next" + + " refresh", + e.status()); + return; + } + // Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch. + entitlementCache.accept(latest); + } + + /** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */ + private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) { + EnumMap cumulative = new EnumMap<>(BillingCategory.class); + for (UsageCounter c : counters.findByPeriodStart(period)) { + BillingCategory cat = c.billingCategory(); + if (cat != null && cat != BillingCategory.BYPASSED) { + cumulative.merge(cat, c.getCumulativeUnits(), Long::sum); + } + } + AccountLinkSyncState state = loadState(); + long seq = reserveNextSeq(state); + InstanceEntitlement fresh = + client.reportUsage( + cred.getDeviceId(), + cred.getDeviceSecret(), + seq, + period, + cumulative.getOrDefault(BillingCategory.API, 0L), + cumulative.getOrDefault(BillingCategory.AI, 0L), + cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L)); + if (fresh == null) { + // Transport/server failure: leave the synced markers untouched. The burned seq is + // harmless (seqs need only be monotonic) and the delta bills on the next successful + // sync. + return null; + } + recordSuccess(period, cumulative, state); + return fresh; + } + + /** Reserves and persists the next strictly-increasing sequence before the report goes out. */ + private long reserveNextSeq(AccountLinkSyncState state) { + long next = state.getLastSyncSeq() + 1; + state.setLastSyncSeq(next); + syncState.save(state); + return next; + } + + /** + * Advances the per-category synced markers to the reported totals + stamps the success time. + */ + private void recordSuccess( + LocalDateTime period, + EnumMap cumulative, + AccountLinkSyncState state) { + cumulative.forEach( + (category, units) -> { + if (units > 0) { + counters.markSynced(period, category.name(), units); + } + }); + state.setLastSuccessAt(LocalDateTime.now()); + syncState.save(state); + } + + private AccountLinkSyncState loadState() { + return syncState + .findById(AccountLinkSyncState.SINGLETON_ID) + .orElseGet( + () -> { + AccountLinkSyncState s = new AccountLinkSyncState(); + s.setId(AccountLinkSyncState.SINGLETON_ID); + return s; + }); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java new file mode 100644 index 0000000000..51ca47ad64 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.billing; + +/** + * The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so + * category is metadata only and never affects price. + * + *

    Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link + * BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never + * billed. + * + *

    Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted + * instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} / + * {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the + * same names. (We deliberately do not share one enum across the modules: that would drag the SaaS + * billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.) + */ +public enum BillingCategory { + BYPASSED, + API, + AI, + AUTOMATION +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java new file mode 100644 index 0000000000..95b3abb99c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java @@ -0,0 +1,29 @@ +package stirling.software.proprietary.billing; + +/** + * Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a + * linked self-hosted instance classify identically. Each backend resolves the three signals from + * its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature} + * annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this + * applies the order {@code AUTOMATION → AI → API → BYPASSED}. + * + *

    An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the + * automation header dominates), while a direct call to it bills as {@code AI}. + */ +public final class BillingCategoryClassifier { + + private BillingCategoryClassifier() {} + + public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) { + if (automation) { + return BillingCategory.AUTOMATION; + } + if (ai) { + return BillingCategory.AI; + } + if (apiKey) { + return BillingCategory.API; + } + return BillingCategory.BYPASSED; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java new file mode 100644 index 0000000000..232dd499dc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java @@ -0,0 +1,68 @@ +package stirling.software.proprietary.billing; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's + * meter (combined-billing "Mode A"), so both derive an identical signature for the same + * bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent + * of file size), hardware-accelerated by the JVM where available. + * + *

    Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core + * build yet is reachable from {@code :saas} (which depends on {@code :proprietary}). + */ +public final class ContentHasher { + + private static final String ALGORITHM = "SHA-256"; + private static final int BUFFER_SIZE = 64 * 1024; + + private ContentHasher() {} + + /** Lower-case hex SHA-256 of the file's bytes. */ + public static String sha256(Path file) throws IOException { + MessageDigest digest = newDigest(); + try (InputStream raw = Files.newInputStream(file); + DigestInputStream in = new DigestInputStream(raw, digest)) { + byte[] buf = new byte[BUFFER_SIZE]; + while (in.read(buf) != -1) { + // drain through the digest; we only want the side effect + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + /** Lower-case hex SHA-256 of the given bytes (e.g. to combine per-file hashes into one key). */ + public static String sha256(byte[] bytes) { + return HexFormat.of().formatHex(newDigest().digest(bytes)); + } + + /** + * A fresh SHA-256 digest, for callers that stream bytes through a {@link + * java.security.DigestOutputStream} to hash in the same pass that writes the file — avoiding a + * second full read just to fingerprint it. Pair with {@link #toHex(byte[])}. + */ + public static MessageDigest newSha256() { + return newDigest(); + } + + /** Lower-case hex of a completed digest — the same format {@link #sha256(Path)} produces. */ + public static String toHex(byte[] digest) { + return HexFormat.of().formatHex(digest); + } + + private static MessageDigest newDigest() { + try { + return MessageDigest.getInstance(ALGORITHM); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by every JDK; unreachable in practice. + throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java new file mode 100644 index 0000000000..2cc22daf41 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java @@ -0,0 +1,76 @@ +package stirling.software.proprietary.billing; + +import java.util.List; + +/** + * Pure doc-unit math shared by the SaaS billing engine and a linked self-hosted instance, so both + * cost an operation identically. No Spring, no IO: callers supply page/byte facts (read however + * their backend reads them — e.g. jpdfium for PDFs) plus a {@link UnitCalcPolicy}. + * + *

    Raw units for one file = the larger of {@code ceil(pages / docPagesPerUnit)} and {@code + * ceil(bytes / docBytesPerUnit)} (non-PDF inputs pass {@code pages = 0}, so only the bytes axis + * contributes). A single file is clamped to {@code [1, fileUnitCap]}; a multi-file group is the + * raw per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not + * per-file-clamped, units so the group cap can actually bind). + * + *

    {@link UnitCalcPolicy#minChargeUnits()} is applied by the charge layer, not here; this + * enforces only an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on + * "non-empty input → at least 1 unit". Extracted verbatim from the SaaS {@code + * DefaultDocumentClassifier} to preserve behaviour. + */ +public final class DocumentUnitCalculator { + + /** Floor for non-empty input. Distinct from {@link UnitCalcPolicy#minChargeUnits()}. */ + public static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; + + private DocumentUnitCalculator() {} + + /** One file's page count (0 for non-PDF / unreadable) and byte size. */ + public record FileSize(int pages, long bytes) {} + + /** Raw (unclamped) units for one file. */ + public static long rawUnits(int pages, long bytes, UnitCalcPolicy policy) { + long pageUnits = pages > 0 ? ceilDiv(pages, policy.docPagesPerUnit()) : 0L; + long byteUnits = ceilDiv(bytes, policy.docBytesPerUnit()); + return Math.max(pageUnits, byteUnits); + } + + /** Units for a single file, clamped to {@code [1, fileUnitCap]}. */ + public static int unitsForFile(int pages, long bytes, UnitCalcPolicy policy) { + long raw = rawUnits(pages, bytes, policy); + // toIntExact: fail loud on overflow rather than silently wrapping a billing number. + return Math.toIntExact( + Math.max(MIN_UNITS_PER_NONEMPTY_FILE, Math.min(policy.fileUnitCap(), raw))); + } + + /** + * Units for a multi-file group: raw per-file sum clamped to {@code [1, fileUnitCap * count]}. + */ + public static int unitsForGroup(List files, UnitCalcPolicy policy) { + if (files.isEmpty()) { + throw new IllegalArgumentException("files must not be empty"); + } + long rawSum = 0; + for (FileSize f : files) { + rawSum = saturatedAdd(rawSum, rawUnits(f.pages(), f.bytes(), policy)); + } + long groupCap = (long) policy.fileUnitCap() * files.size(); + return Math.toIntExact( + Math.max((long) MIN_UNITS_PER_NONEMPTY_FILE, Math.min(groupCap, rawSum))); + } + + private static long ceilDiv(long numerator, long divisor) { + if (numerator <= 0) { + return 0; + } + return (numerator + divisor - 1) / divisor; + } + + private static long saturatedAdd(long a, long b) { + try { + return Math.addExact(a, b); + } catch (ArithmeticException e) { + return Long.MAX_VALUE; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java new file mode 100644 index 0000000000..b7d773d465 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.billing; + +/** + * The four billing knobs the doc-unit math needs, split out of the SaaS {@code PricingPolicy} JPA + * entity so the calculation ({@link DocumentUnitCalculator}) can live in {@code :proprietary} and + * be shared by the SaaS billing engine and a linked self-hosted instance — both then cost an + * operation identically. + * + *

    The SaaS engine builds one from its persisted {@code PricingPolicy}; a linked instance + * receives these values in the daily entitlement sync. {@code minChargeUnits} is carried here for + * the charge layer; {@link DocumentUnitCalculator} itself does not apply it (see its docs). + */ +public record UnitCalcPolicy( + int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) { + + public UnitCalcPolicy { + if (docPagesPerUnit <= 0) { + throw new IllegalArgumentException("docPagesPerUnit must be > 0"); + } + if (docBytesPerUnit <= 0) { + throw new IllegalArgumentException("docBytesPerUnit must be > 0"); + } + if (minChargeUnits < 1) { + throw new IllegalArgumentException("minChargeUnits must be >= 1"); + } + if (fileUnitCap < 1) { + throw new IllegalArgumentException("fileUnitCap must be >= 1"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java index 7d954a4398..969111e9f5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -12,6 +12,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -189,4 +190,73 @@ void revokeSelfReturnsFalseWhenUnreachable() throws Exception { .thenThrow(new ConnectException("refused")); assertEquals(false, client.revokeSelf("dev-1", "sec-1")); } + + @Test + @SuppressWarnings("unchecked") + void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception { + HttpResponse resp = + response( + 200, + "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + InstanceEntitlement e = + client.reportUsage( + "dev-1", "sec-1", 7L, LocalDateTime.of(2026, 6, 1, 0, 0), 12, 4, 8); + + assertNotNull(e); + assertEquals(42, e.periodSpendUnits()); + assertEquals(EntitlementState.OK, e.state()); + + HttpRequest sent = captor.getValue(); + assertEquals("https://saas.example.com/api/v1/instance/sync", sent.uri().toString()); + assertEquals("POST", sent.method()); + assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null)); + assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageThrowsRevokedOnDeny() throws Exception { + for (int status : new int[] {401, 403}) { + HttpResponse resp = response(status, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + AccountLinkClient.RevokedException ex = + assertThrows( + AccountLinkClient.RevokedException.class, + () -> + client.reportUsage( + "dev-1", + "sec-1", + 1L, + LocalDateTime.of(2026, 6, 1, 0, 0), + 1, + 0, + 0)); + assertEquals(status, ex.status()); + } + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageReturnsNullWhenUnreachable() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenThrow(new ConnectException("refused")); + // Null = don't advance synced markers; the usage retries on the next sync. + assertNull( + client.reportUsage( + "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0)); + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageReturnsNullOnServerError() throws Exception { + HttpResponse resp = response(503, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + assertNull( + client.reportUsage( + "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0)); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java index 6e4a816bb0..41f544de7e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -2,12 +2,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -21,12 +24,18 @@ class AccountLinkControllerTest { private AccountLinkService service; + private UsageSyncService syncService; + private ObjectProvider syncProvider; private AccountLinkController controller; @BeforeEach + @SuppressWarnings("unchecked") void setUp() { service = mock(AccountLinkService.class); - controller = new AccountLinkController(service); + syncService = mock(UsageSyncService.class); + syncProvider = mock(ObjectProvider.class); + controller = + new AccountLinkController(service, mock(LocalUsageService.class), syncProvider); } @Test @@ -65,4 +74,24 @@ void link_transportFailure_maps502() throws Exception { ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); } + + @Test + void syncNow_triggersSyncWhenMeteringOn() { + when(syncProvider.getIfAvailable()).thenReturn(syncService); + + ResponseEntity resp = controller.syncNow(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(syncService).syncNow(); + } + + @Test + void syncNow_returns409WhenMeteringOff() { + when(syncProvider.getIfAvailable()).thenReturn(null); // metering disabled → bean absent + + ResponseEntity resp = controller.syncNow(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + verify(syncService, never()).syncNow(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java index 275026794a..0b43069ee9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java @@ -1,49 +1,78 @@ package stirling.software.proprietary.accountlink; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; class BillableOperationClassifierTest { + private static MockHttpServletRequest req(String uri) { + return new MockHttpServletRequest("POST", uri); + } + @Test - void aiPathIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo"); - assertTrue(BillableOperationClassifier.isBillable(req)); + void aiPathIsAi() { + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false)); } @Test - void automationHeaderIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + void automationHeaderIsAutomation() { + MockHttpServletRequest req = req("/api/v1/general/merge"); req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals( + BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false)); + } + + @Test + void apiKeyToolCallIsApi() { + assertEquals( + BillingCategory.API, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true)); + } + + @Test + void plainManualToolIsBypassed() { + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false)); + } + + @Test + void automationDominatesAiAndApiKey() { + // An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION. + MockHttpServletRequest req = req("/api/v1/ai/tools/foo"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true"); + assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true)); } @Test - void plainManualToolIsFree() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void aiDominatesApiKey() { + // A direct API-key call to an AI tool bills as AI, not API. + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true)); } @Test - void aiSegmentNotAtPathStartIsFree() { - // Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a - // proxy prefix) must NOT classify a manual tool as billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void aiSegmentNotAtPathStartIsBypassed() { + // Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix) + // must NOT classify a manual tool as AI. + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false)); } @Test - void aiPathUnderContextPathIsBillable() { - // A real context-path deployment still classifies: //api/v1/ai/** is billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo"); + void aiPathUnderContextPathIsAi() { + // A real context-path deployment still classifies: //api/v1/ai/** is AI. + MockHttpServletRequest req = req("/stirling/api/v1/ai/tools/foo"); req.setContextPath("/stirling"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals(BillingCategory.AI, BillableOperationClassifier.categorize(req, false)); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java index 0c250fd3e9..1838034a9a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java @@ -3,20 +3,32 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; +import java.time.LocalDateTime; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.accountlink.GateDecision.Reason; /** - * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and - * over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is - * needed. + * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired, + * linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no + * Spring; the grace-window reference computation is exercised through {@link + * InstanceEntitlementGate#evaluate} with mocked collaborators. */ +@ExtendWith(MockitoExtension.class) class InstanceEntitlementGateTest { + @Mock private DeviceCredentialStore credentialStore; + @Mock private EntitlementCache entitlementCache; + @Mock private AccountLinkSyncStateRepository syncStateRepository; + @Mock private LocalUsageService localUsageService; + private static InstanceEntitlement free() { return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK); } @@ -35,35 +47,67 @@ private static InstanceEntitlement subscribedOverCap() { @Test void flagOff_allowsEverything_evenBillableUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FLAG_OFF, d.reason()); } @Test void manualTool_alwaysFree_evenUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.MANUAL_FREE, d.reason()); } @Test void billable_notLinked_blocksWithLinkSignal() { - GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(true, true, false, Optional.empty(), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.NOT_LINKED, d.reason()); } @Test - void billable_linked_entitlementUnreachable_failsOpen() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty()); + void billable_linked_entitlementUnreachable_withinGrace_failsOpen() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FAIL_OPEN, d.reason()); } + @Test + void billable_linked_entitlementUnreachable_graceExpired_blocks() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L); + assertFalse(d.allowed()); + assertEquals(Reason.GRACE_EXPIRED, d.reason()); + } + @Test void billable_linked_freePoolAvailable_allows() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free())); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 0L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_unsubscribed_pendingLocalUsageDepletesGrant_blocks() { + // free() has 100 free units left per the last sync; 100 accrued locally since would exhaust + // it once charged, so the gate stops here in real time rather than waiting for the sync. + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 100L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_unsubscribed_pendingLocalUsageLeavesRoom_allows() { + // 99 pending against 100 remaining → one unit of grant still projected free → allow. + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 99L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -72,7 +116,7 @@ void billable_linked_freePoolAvailable_allows() { void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(exhaustedUnsubscribed())); + true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -81,7 +125,7 @@ void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { void billable_linked_subscribedWithinCap_allows() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(subscribedWithinCap())); + true, true, true, Optional.of(subscribedWithinCap()), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -89,18 +133,67 @@ void billable_linked_subscribedWithinCap_allows() { @Test void billable_linked_subscribedOverCap_blocks() { GateDecision d = - InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap())); + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedOverCap()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } + @Test + void billable_linked_subscribedCapped_pendingLocalUsageWouldExceedCap_blocks() { + // Within cap per the last sync (spend 10 / cap 100), but 95 accrued locally since would + // push + // projected spend to 105 → the gate stops now, not after the next sync reconciles. + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap()), false, 95L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_subscribedCapped_pendingLeavesCapRoom_allows() { + // 10 synced + 80 pending = 90 < 100 cap → still room. + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap()), false, 80L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedCapped_freeGrantAbsorbsPending_allows() { + // 50 free units remain, so 40 pending is entirely free → 0 projected paid < 100 cap → + // allow. + InstanceEntitlement subscribedWithGrant = + new InstanceEntitlement(true, 50, 0, 100L, EntitlementState.OK); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithGrant), false, 40L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedUncapped_pendingIgnored_allows() { + // No cap → local pending has no ceiling to hit → always allowed. + InstanceEntitlement uncapped = + new InstanceEntitlement(true, 0, 999, null, EntitlementState.OK); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(uncapped), false, 500L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + @Test void billable_linked_revoked_blocksWithRevokedSignal() { // Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED — // blocks distinctly from over-limit, even though the snapshot is "present". InstanceEntitlement revoked = new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked)); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.REVOKED, d.reason()); } @@ -110,7 +203,98 @@ void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() { // Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive. InstanceEntitlement conflicting = new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting)); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(conflicting), false, 0L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + // --- grace window (evaluate()) --------------------------------------------------------------- + + private InstanceEntitlementGate gate(AccountLinkProperties props) { + return new InstanceEntitlementGate( + props, credentialStore, entitlementCache, syncStateRepository, localUsageService); + } + + private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) { + AccountLinkProperties p = new AccountLinkProperties(); + p.setEnabled(true); + p.getMetering().setEnabled(meteringEnabled); + p.getMetering().setGraceDays(graceDays); + return p; + } + + @Test + void evaluate_meteringOff_unreachable_failsOpen_neverGraceBlocks() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + + GateDecision d = gate(props(false, 3)).evaluate(true); + + // Metering off → grace never applies, even if a sync is ancient. + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void evaluate_neverSynced_pastGraceSinceLink_blocks() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + .thenReturn(Optional.empty()); + DeviceCredential cred = new DeviceCredential(); + cred.setLinkedAt(LocalDateTime.now().minusDays(5)); + when(credentialStore.get()).thenReturn(Optional.of(cred)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertFalse(d.allowed()); + assertEquals(Reason.GRACE_EXPIRED, d.reason()); + } + + @Test + void evaluate_recentSync_withinGrace_failsOpen() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setLastSuccessAt(LocalDateTime.now().minusDays(1)); + when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + .thenReturn(Optional.of(state)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void evaluate_unsubscribed_localUsageWouldExceedGrant_blocksInRealTime() { + // 100 free units remaining per the last sync, but 100 already accrued locally since — the + // gate subtracts the pending delta and blocks now, not after the next sync reconciles. + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.of(free())); + when(localUsageService.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 100, 0, 0, 100)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void evaluate_subscribedCapped_localUsageWouldExceedCap_blocksInRealTime() { + // Subscribed within cap per the last sync (spend 10 / cap 100), but 90 accrued locally + // since — evaluate() now depletes the cap by pending usage for capped subscriptions too, so + // the gate stops now instead of overshooting the cap until the next sync. + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.of(subscribedWithinCap())); + when(localUsageService.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 0, 90, 0, 90)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java index f764f5e836..06b99e0eb5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java @@ -19,6 +19,7 @@ class InstanceEntitlementGateWiringTest { private AccountLinkProperties properties; private DeviceCredentialStore store; private EntitlementCache cache; + private LocalUsageService localUsage; private InstanceEntitlementGate gate; @BeforeEach @@ -27,7 +28,14 @@ void setUp() { properties.setEnabled(true); store = mock(DeviceCredentialStore.class); cache = mock(EntitlementCache.class); - gate = new InstanceEntitlementGate(properties, store, cache); + localUsage = mock(LocalUsageService.class); + gate = + new InstanceEntitlementGate( + properties, + store, + cache, + mock(AccountLinkSyncStateRepository.class), + localUsage); } @Test @@ -55,6 +63,10 @@ void billableLinkedConsultsCache() { .thenReturn( Optional.of( new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK))); + // Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time; + // nothing pending here, so the 5 free units still allow the request. + when(localUsage.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0)); GateDecision d = gate.evaluate(true); assertTrue(d.allowed()); assertEquals(GateDecision.Reason.ENTITLED, d.reason()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java index 709e3c0866..13a2606105 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java @@ -3,24 +3,55 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockMultipartHttpServletRequest; + +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.UnitCalcPolicy; @ExtendWith(MockitoExtension.class) class InstanceEntitlementInterceptorTest { @Mock private InstanceEntitlementGate gate; + @Mock private EntitlementCache entitlementCache; + @Mock private ObjectProvider meterProvider; + @Mock private TempFileManager tempFileManager; + + private InstanceEntitlementInterceptor interceptor() { + return new InstanceEntitlementInterceptor( + gate, entitlementCache, meterProvider, tempFileManager); + } private boolean preHandle(MockHttpServletResponse response) throws Exception { - return new InstanceEntitlementInterceptor(gate) + return interceptor() .preHandle( new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object()); } @@ -58,4 +89,85 @@ void failsOpenWhenGateThrows() throws Exception { assertTrue(preHandle(response)); assertEquals(200, response.getStatus()); } + + @Test + void metersSuccessfulBillableOp() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); // stashes AI category + interceptor.afterCompletion(req, resp, new Object(), null); + + // No uploaded files → bytes axis → the 1-unit floor; no input identity → null signature. + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L), isNull()); + } + + @Test + void metersPdfByPageCountNotJustBytes(@TempDir Path tmp) throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + // docPagesPerUnit=1, docBytesPerUnit=1MB → a tiny 5-page PDF costs 5 on the page axis but + // only 1 on the byte axis: page-counting (via jpdfium) is what makes this bill correctly. + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); + // Materialise to a real path under @TempDir; the interceptor writes the upload there and + // jpdfium + the hasher read it back. + TempFile temp = mock(TempFile.class); + when(temp.getPath()).thenReturn(tmp.resolve("input.bin")); + when(tempFileManager.createManagedTempFile(any())).thenReturn(temp); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest(); + req.setRequestURI("/api/v1/ai/x"); + req.addFile(new MockMultipartFile("file", "doc.pdf", "application/pdf", fivePagePdf())); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + // 5 pages + a non-null input-set signature (file ops carry a dedup key). + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L), notNull()); + } + + @Test + void doesNotMeterWhenMeteringSwitchOff() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + when(meterProvider.getIfAvailable()).thenReturn(null); // metering.enabled = false + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + // Meter absent → no entitlement lookup, no accrual. + verifyNoInteractions(entitlementCache); + } + + private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) { + return new InstanceEntitlement( + true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1)); + } + + private static byte[] fivePagePdf() throws Exception { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + for (int i = 0; i < 5; i++) { + doc.addPage(new PDPage()); + } + doc.save(out); + return out.toByteArray(); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java new file mode 100644 index 0000000000..4605348666 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java @@ -0,0 +1,75 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LocalUsageServiceTest { + + @Mock private UsageCounterRepository counters; + @Mock private EntitlementCache entitlementCache; + + private LocalUsageService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new LocalUsageService(counters, entitlementCache); + } + + private static UsageCounter counter( + LocalDateTime period, String category, long cumulative, long synced) { + return new UsageCounter(period, category, cumulative, synced, LocalDateTime.now()); + } + + private static InstanceEntitlement entitledFor(LocalDateTime periodStart) { + return new InstanceEntitlement( + true, + 0, + 0, + null, + EntitlementState.OK, + null, + periodStart, + periodStart.plusMonths(1)); + } + + @Test + void unknownPeriodReturnsZeros() { + when(entitlementCache.current()).thenReturn(Optional.empty()); + + LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced(); + + assertThat(usage.periodStart()).isNull(); + assertThat(usage.totalUnsyncedUnits()).isZero(); + } + + @Test + void sumsPerCategoryUnsyncedDeltaForCurrentPeriod() { + when(entitlementCache.current()).thenReturn(Optional.of(entitledFor(period))); + when(counters.findByPeriodStart(period)) + .thenReturn( + List.of( + counter(period, "API", 30L, 10L), // 20 unsynced + counter(period, "AI", 4L, 4L), // 0 unsynced (all reported) + counter(period, "AUTOMATION", 7L, 2L))); // 5 unsynced + + LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced(); + + assertThat(usage.periodStart()).isEqualTo(period); + assertThat(usage.apiUnsyncedUnits()).isEqualTo(20L); + assertThat(usage.aiUnsyncedUnits()).isEqualTo(0L); + assertThat(usage.automationUnsyncedUnits()).isEqualTo(5L); + assertThat(usage.totalUnsyncedUnits()).isEqualTo(25L); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java new file mode 100644 index 0000000000..0c9ad2c62e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java @@ -0,0 +1,133 @@ +package stirling.software.proprietary.accountlink; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import stirling.software.proprietary.billing.BillingCategory; + +@ExtendWith(MockitoExtension.class) +class UsageMeterServiceTest { + + @Mock private UsageCounterRepository repo; + @Mock private MeteredInputSignatureRepository signatureRepo; + + private UsageMeterService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new UsageMeterService(repo, signatureRepo, new AccountLinkProperties()); + } + + @Test + void incrementsExistingCounter() { + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, null); + + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + verify(repo, never()).saveAndFlush(any()); + } + + @Test + void insertsWhenNoRowExists() { + when(repo.increment(eq(period), eq("API"), eq(3L), any())).thenReturn(0); + + service.accrue(period, BillingCategory.API, 3, null); + + verify(repo).saveAndFlush(any(UsageCounter.class)); + } + + @Test + void retriesIncrementWhenInsertLosesRace() { + // First increment misses (no row); insert loses the race to a concurrent thread; the + // second increment then succeeds against the row that thread created. + when(repo.increment(eq(period), eq("AUTOMATION"), eq(2L), any())).thenReturn(0, 1); + when(repo.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("dup")); + + service.accrue(period, BillingCategory.AUTOMATION, 2, null); + + verify(repo, times(2)).increment(eq(period), eq("AUTOMATION"), eq(2L), any()); + } + + @Test + void skipsBypassedNonPositiveAndNullPeriod() { + service.accrue(period, BillingCategory.BYPASSED, 5, null); + service.accrue(period, BillingCategory.AI, 0, null); + service.accrue(null, BillingCategory.AI, 5, null); + + verifyNoInteractions(repo, signatureRepo); + } + + @Test + void chargesNewSignatureThenAccrues() { + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-new")) + .thenReturn(Optional.empty()); + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, "op-sig-new"); + + verify(signatureRepo).saveAndFlush(any(MeteredInputSignature.class)); + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + } + + @Test + void skipsConcurrentDuplicateClaim() { + // Unseen this period, but a concurrent op wins the insert first → treated as within-window + // chaining, not re-charged. + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-race")) + .thenReturn(Optional.empty()); + when(signatureRepo.saveAndFlush(any())) + .thenThrow(new DataIntegrityViolationException("dup")); + + service.accrue(period, BillingCategory.AI, 5, "op-sig-race"); + + verify(repo, never()).increment(any(), any(), anyLong(), any()); + verify(repo, never()).saveAndFlush(any()); + } + + @Test + void skipsRepeatWithinWorkflowWindow() { + // Same input set seen moments ago → chaining → not re-charged; the window slides. + MeteredInputSignature recent = + new MeteredInputSignature(period, "op-sig", LocalDateTime.now()); + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig")) + .thenReturn(Optional.of(recent)); + + service.accrue(period, BillingCategory.AI, 5, "op-sig"); + + verify(repo, never()).increment(any(), any(), anyLong(), any()); + verify(signatureRepo).save(recent); // window touched + } + + @Test + void chargesRepeatOutsideWorkflowWindow() { + // Same input set last seen well past the 5-minute window → an independent re-run → charged. + MeteredInputSignature stale = + new MeteredInputSignature(period, "op-sig", LocalDateTime.now().minusMinutes(10)); + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig")) + .thenReturn(Optional.of(stale)); + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, "op-sig"); + + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + verify(signatureRepo).save(stale); // window touched + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java new file mode 100644 index 0000000000..1b1ade7e80 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java @@ -0,0 +1,171 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +@ExtendWith(MockitoExtension.class) +class UsageSyncServiceTest { + + @Mock private UsageCounterRepository counters; + @Mock private AccountLinkSyncStateRepository syncState; + @Mock private DeviceCredentialStore credentialStore; + @Mock private AccountLinkClient client; + @Mock private EntitlementCache entitlementCache; + + private UsageSyncService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = + new UsageSyncService( + counters, + syncState, + credentialStore, + client, + entitlementCache, + new AccountLinkProperties()); + } + + @Test + void registersFixedDelayTaskWithConfiguredInterval() { + AccountLinkProperties props = new AccountLinkProperties(); + props.getMetering().setSyncIntervalHours(6); + UsageSyncService svc = + new UsageSyncService( + counters, syncState, credentialStore, client, entitlementCache, props); + + ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar(); + svc.configureTasks(registrar); + + // Pins the interval binding in CI — the old @Scheduled SpEL only resolved at flags-on boot. + assertThat(registrar.getFixedDelayTaskList()).hasSize(1); + assertThat(registrar.getFixedDelayTaskList().get(0).getIntervalDuration()) + .isEqualTo(Duration.ofHours(6)); + } + + private static DeviceCredential credential() { + DeviceCredential c = new DeviceCredential(); + c.setDeviceId("dev-1"); + c.setDeviceSecret("sec-1"); + return c; + } + + private static UsageCounter counter(LocalDateTime period, String category, long cumulative) { + return new UsageCounter(period, category, cumulative, LocalDateTime.now()); + } + + private static InstanceEntitlement entitled() { + return new InstanceEntitlement(true, 0, 0, null, EntitlementState.OK); + } + + @Test + void notLinkedSkipsEntirely() { + when(credentialStore.get()).thenReturn(Optional.empty()); + + service.syncNow(); + + verifyNoInteractions(client, entitlementCache); + verify(counters, never()).findPeriodsWithUnsyncedUsage(); + } + + @Test + void nothingPendingStillForcesEntitlementRefresh() { + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of()); + + service.syncNow(); + + // No usage to report, so nothing is sent and no markers advance — but the sync still forces + // an entitlement refresh so an out-of-band plan change (e.g. a just-completed subscription) + // surfaces on the gate immediately instead of waiting out the entitlement-cache TTL. + verifyNoInteractions(client); + verify(syncState, never()).save(any()); + verify(entitlementCache, never()).accept(any()); + verify(entitlementCache).invalidate(); + verify(entitlementCache).current(); + } + + @Test + void reportsCumulativePerCategoryAndAdvancesSyncedMarkers() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + state.setLastSyncSeq(5L); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)) + .thenReturn(List.of(counter(period, "API", 12L), counter(period, "AI", 4L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + InstanceEntitlement fresh = entitled(); + when(client.reportUsage( + eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L))) + .thenReturn(fresh); + + service.syncNow(); + + // Seq advanced from 5 → 6 and the report carried the per-category cumulative. + verify(client) + .reportUsage(eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L)); + // Only categories with usage are marked; AUTOMATION (0) is skipped. + verify(counters).markSynced(period, "API", 12L); + verify(counters).markSynced(period, "AI", 4L); + verify(counters, never()).markSynced(eq(period), eq("AUTOMATION"), anyLong()); + // Two saves: the pre-report seq reservation + the post-success timestamp. + verify(syncState, times(2)).save(state); + verify(entitlementCache).accept(fresh); + } + + @Test + void transportFailureReservesSeqButLeavesMarkersUntouched() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong())) + .thenReturn(null); + + service.syncNow(); + + verify(counters, never()).markSynced(any(), any(), anyLong()); + verify(syncState, times(1)).save(state); // seq reserved, success not recorded + verify(entitlementCache).accept(null); // nothing fresh adopted + } + + @Test + void revokedAbortsWithoutMarkingOrAdoptingEntitlement() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong())) + .thenThrow(new AccountLinkClient.RevokedException(403)); + + service.syncNow(); + + verify(counters, never()).markSynced(any(), any(), anyLong()); + verify(entitlementCache, never()).accept(any()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java new file mode 100644 index 0000000000..1eb6c3360a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.billing; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class BillingCategoryClassifierTest { + + @Test + void automationWinsOverEverything() { + assertEquals( + BillingCategory.AUTOMATION, BillingCategoryClassifier.classify(true, true, true)); + } + + @Test + void aiWinsOverApiKey() { + assertEquals(BillingCategory.AI, BillingCategoryClassifier.classify(false, true, true)); + } + + @Test + void apiKeyWhenNotAutomationOrAi() { + assertEquals(BillingCategory.API, BillingCategoryClassifier.classify(false, false, true)); + } + + @Test + void bypassedWhenNoSignal() { + assertEquals( + BillingCategory.BYPASSED, BillingCategoryClassifier.classify(false, false, false)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java index 7392eb928a..7c98d5e59b 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java @@ -1,5 +1,8 @@ package stirling.software.saas.accountlink; +import java.time.LocalDateTime; +import java.util.Map; + import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -9,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -16,11 +20,16 @@ import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; import stirling.software.saas.payg.entitlement.EntitlementService; import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.instance.InstanceUsageIngestService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; /** * Instance-facing surface (combined-billing "Mode A"), authenticated by the device @@ -46,14 +55,23 @@ public class InstanceController { private final EntitlementService entitlementService; private final TeamBillingService billingService; private final AccountLinkService accountLinkService; + private final PricingPolicyService pricingPolicyService; + private final InstanceUsageIngestService usageIngestService; + private final LinkedInstanceRepository linkedInstanceRepository; public InstanceController( EntitlementService entitlementService, TeamBillingService billingService, - AccountLinkService accountLinkService) { + AccountLinkService accountLinkService, + PricingPolicyService pricingPolicyService, + InstanceUsageIngestService usageIngestService, + LinkedInstanceRepository linkedInstanceRepository) { this.entitlementService = entitlementService; this.billingService = billingService; this.accountLinkService = accountLinkService; + this.pricingPolicyService = pricingPolicyService; + this.usageIngestService = usageIngestService; + this.linkedInstanceRepository = linkedInstanceRepository; } public record WhoAmIResponse(Long instanceId, Long teamId) {} @@ -68,7 +86,13 @@ public record EntitlementResponse( long freeRemainingUnits, long periodSpendUnits, Long periodCapUnits, - String state) {} + String state, + // Metering inputs the instance needs to cost + bucket its own usage (Phase 2). The + // instance computes units locally with this policy and resets its per-period cumulative + // counters on the [periodStart, periodEnd) boundary. + UnitCalcPolicy unitCalcPolicy, + LocalDateTime periodStart, + LocalDateTime periodEnd) {} @GetMapping("/whoami") @PreAuthorize("hasRole('LINKED_INSTANCE')") @@ -103,20 +127,96 @@ public ResponseEntity entitlement(Authentication auth) { if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + // Drop the cached snapshot first: this low-frequency read gates real-time billable work, so + // it must reflect a just-changed subscription/cap at once (the flip is a DB-function write + // with no Java event to invalidate on). + entitlementService.invalidate(token.getTeamId()); + return ResponseEntity.ok(buildEntitlement(token.getTeamId())); + } + + /** Body for {@code POST /sync}: the instance's cumulative units per category this period. */ + public record UsageSyncRequest( + long syncSeq, LocalDateTime periodStart, CategoryUnits cumulativeUnits) { + public record CategoryUnits(long api, long ai, long automation) {} + } + + /** + * Daily usage sync: the instance reports its cumulative per-category unit totals for the + * period; SaaS bills the delta since the last sync (reusing the standard charge path) and + * returns the fresh entitlement — so one round-trip both reports usage and refreshes the gate + * state. + */ + @PostMapping("/sync") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + @Transactional + public ResponseEntity sync( + Authentication auth, @RequestBody UsageSyncRequest req) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + if (req == null || req.periodStart() == null || req.cumulativeUnits() == null) { + return ResponseEntity.badRequest().build(); + } Long teamId = token.getTeamId(); + // periodStart is the dedup/regression partition key, so bound a fabricated value to the + // snapshot window (current or immediately-prior period, never future). + EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); + LocalDateTime reported = req.periodStart(); + if (!reported.isBefore(snap.periodEnd()) + || reported.isBefore(snap.periodStart().minusMonths(1))) { + log.warn( + "Instance sync for team {} reported implausible periodStart {} (authoritative" + + " {}..{}); rejecting.", + teamId, + reported, + snap.periodStart(), + snap.periodEnd()); + return ResponseEntity.badRequest().build(); + } + // Attribute the charge to the admin who linked the instance (the device credential carries + // no user). Null is tolerated by the ingest service (it skips + retries next sync). + Long actorUserId = + linkedInstanceRepository + .findById(token.getInstanceId()) + .map(LinkedInstance::getCreatedByUserId) + .orElse(null); + UsageSyncRequest.CategoryUnits c = req.cumulativeUnits(); + usageIngestService.ingest( + teamId, + actorUserId, + req.syncSeq(), + req.periodStart(), + Map.of( + BillingCategory.API, c.api(), + BillingCategory.AI, c.ai(), + BillingCategory.AUTOMATION, c.automation())); + // Drop the cache so the buildEntitlement below (and the portal's next read) reflect the + // just-charged delta + moved free-grant balance now, not after the TTL. + entitlementService.invalidate(teamId); + return ResponseEntity.ok(buildEntitlement(teamId)); + } + /** The entitlement view shared by {@code GET /entitlement} and the {@code /sync} response. */ + private EntitlementResponse buildEntitlement(Long teamId) { // Same composition the FE wallet uses: billing facts (subscription, free pool) from - // TeamBillingService, period spend/cap + state from the entitlement snapshot. + // TeamBillingService, period spend/cap + state from the entitlement snapshot, plus the + // unit-calc policy + period the instance needs to meter locally. TeamBillingContext billing = billingService.forTeam(teamId); EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); - - return ResponseEntity.ok( - new EntitlementResponse( - billing.subscribed(), - billing.freeRemainingUnits(), - snap.periodSpendUnits(), - snap.periodCapUnits(), - coarseState(snap.state()))); + PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId); + return new EntitlementResponse( + billing.subscribed(), + billing.freeRemainingUnits(), + snap.periodSpendUnits(), + snap.periodCapUnits(), + coarseState(snap.state()), + new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()), + snap.periodStart(), + snap.periodEnd()); } /** diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index 041eaa8f4a..b79d57117e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -328,6 +329,32 @@ public ResponseEntity updateCap( /** Request body for {@link #updateCap}. */ public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {} + // --------------------------------------------------------------------------------------- + // POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh + // --------------------------------------------------------------------------------------- + + /** + * Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a + * billing state that just changed out-of-band. The subscription flip is written by a Postgres + * function ({@code payg_link_subscription}) with no Java event to invalidate on, so a client + * that knows a change just happened — the portal while finalizing a checkout — pokes the cache + * here rather than waiting out the ~30s TTL. Team-scoped to the caller: a client can only + * refresh its own team, and a no-team caller is a cheap no-op. + */ + @PostMapping("/wallet/refresh") + @PreAuthorize("isAuthenticated()") + public ResponseEntity refreshWallet(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + primaryMembership(user.getId()) + .ifPresent(m -> entitlementService.invalidate(m.getTeam().getId())); + return ResponseEntity.noContent().build(); + } + // --------------------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------------------- diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java index 603f490a4f..04b9b182a2 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java @@ -5,6 +5,7 @@ import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -18,6 +19,9 @@ import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.jpdfium.PdfDocument; +import stirling.software.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.payg.policy.PricingPolicy; /** @@ -42,9 +46,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier { private static final String PDF_CONTENT_TYPE = "application/pdf"; private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; - /** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */ - private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; - private final TempFileManager tempFileManager; @Override @@ -59,13 +60,7 @@ public DocumentMetrics classify( Objects.requireNonNull(policy, "policy"); FileFacts facts = inspect(file, materialisedPath); - long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy); - // toIntExact: fail loud on overflow rather than silently wrapping a billing number. - int units = - Math.toIntExact( - Math.max( - MIN_UNITS_PER_NONEMPTY_FILE, - Math.min(policy.getFileUnitCap(), rawUnits))); + int units = DocumentUnitCalculator.unitsForFile(facts.pages, facts.bytes, unitCalc(policy)); return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units); } @@ -93,17 +88,16 @@ public DocumentMetrics classify( int totalPages = 0; long totalBytes = 0; - long rawUnitsSum = 0; String firstContentType = null; + List sizes = new ArrayList<>(files.size()); for (int i = 0; i < files.size(); i++) { MultipartFile file = files.get(i); Path path = materialisedPaths == null ? null : materialisedPaths.get(i); FileFacts facts = inspect(file, path); - // Sum the *raw* (unclamped) per-file units so the group cap below can actually bind. - // Per-file clamping in this loop would make the group cap a no-op. - rawUnitsSum = - saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy)); + // Collect raw page/byte facts; the group cap is applied over the raw sum in the + // calculator (per-file clamping here would make the group cap a no-op). + sizes.add(new FileSize(facts.pages, facts.bytes)); totalPages = saturatedAdd(totalPages, facts.pages); totalBytes = saturatedAdd(totalBytes, facts.bytes); if (firstContentType == null) { @@ -111,13 +105,7 @@ public DocumentMetrics classify( } } - long groupCap = (long) policy.getFileUnitCap() * files.size(); - // toIntExact: fail loud on overflow rather than silently wrapping. - int totalUnits = - Math.toIntExact( - Math.max( - (long) MIN_UNITS_PER_NONEMPTY_FILE, - Math.min(groupCap, rawUnitsSum))); + int totalUnits = DocumentUnitCalculator.unitsForGroup(sizes, unitCalc(policy)); return new DocumentMetrics( totalPages, @@ -126,6 +114,14 @@ public DocumentMetrics classify( totalUnits); } + private static UnitCalcPolicy unitCalc(PricingPolicy policy) { + return new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()); + } + private FileFacts inspect(MultipartFile file, Path materialisedPath) { long bytes = file.getSize(); String contentType = @@ -140,19 +136,6 @@ private FileFacts inspect(MultipartFile file, Path materialisedPath) { return new FileFacts(pages, bytes, contentType); } - private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) { - long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L; - long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit()); - return Math.max(pageUnits, byteUnits); - } - - private static long ceilDiv(long numerator, long divisor) { - if (numerator <= 0) { - return 0; - } - return (numerator + divisor - 1) / divisor; - } - private static boolean isPdf(String contentType, String filename) { if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) { return true; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java new file mode 100644 index 0000000000..291a46880c --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -0,0 +1,130 @@ +package stirling.software.saas.payg.instance; + +import java.time.LocalDateTime; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.charge.ChargeContext; +import stirling.software.saas.payg.charge.JobChargeService; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.model.ProcessType; +import stirling.software.saas.payg.repository.PaygInstanceUsageRepository; + +/** + * Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a + * monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the + * last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, + * ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and + * tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The + * cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class InstanceUsageIngestService { + + private final PaygInstanceUsageRepository usageRepository; + private final JobChargeService chargeService; + + public InstanceUsageIngestService( + PaygInstanceUsageRepository usageRepository, JobChargeService chargeService) { + this.usageRepository = usageRepository; + this.chargeService = chargeService; + } + + /** + * Bills the delta for each category and advances the last-seen cumulative + sync sequence. The + * delta-advance and the charge share this transaction, so a crash before commit re-bills + * cleanly on retry (delta unchanged) and a commit means the cumulative moved with the charge. + * + * @param actorUserId the linking admin ({@code linked_instance.created_by_user_id}); required + * to attribute the charge. If {@code null} we skip entirely (don't advance) so a later + * sync, once the actor is resolvable, still bills the usage. + */ + @Transactional + public void ingest( + Long teamId, + Long actorUserId, + long syncSeq, + LocalDateTime periodStart, + Map cumulativeByCategory) { + if (teamId == null || periodStart == null || cumulativeByCategory == null) { + return; + } + if (actorUserId == null) { + log.warn( + "Instance usage sync for team {} has no actor (created_by_user_id null); not" + + " billing — a later sync will pick it up.", + teamId); + return; + } + cumulativeByCategory.forEach( + (category, cumulative) -> { + if (category == null + || category == BillingCategory.BYPASSED + || cumulative == null + || cumulative < 0) { + return; + } + applyCategory(teamId, actorUserId, syncSeq, periodStart, category, cumulative); + }); + } + + private void applyCategory( + Long teamId, + Long actorUserId, + long syncSeq, + LocalDateTime periodStart, + BillingCategory category, + long cumulative) { + // Pessimistic row lock so a duplicate delivery can't have two txns read the same baseline + // and both charge: the second waits, then sees the advanced seq and replay-skips. + PaygInstanceUsage row = + usageRepository + .findByTeamIdAndPeriodStartAndCategoryForUpdate( + teamId, periodStart, category.name()) + .orElse(null); + if (row != null && syncSeq <= row.getLastSyncSeq()) { + return; // replay / out-of-order — already applied this or a later sync + } + long lastCumulative = row == null ? 0L : row.getLastCumulativeUnits(); + long delta = cumulative - lastCumulative; + if (delta < 0) { + // The cumulative counter went backwards — a reset or tampering. Refuse to credit; don't + // advance, so the discrepancy stays visible and a corrected resend can reconcile. + log.warn( + "Instance usage regression team={} category={} reported {} < last {}; ignoring.", + teamId, + category, + cumulative, + lastCumulative); + return; + } + if (delta > 0) { + int units = (int) Math.min(delta, Integer.MAX_VALUE); + chargeService.chargeStandalone( + new ChargeContext( + actorUserId, + teamId, + JobSource.LINKED_INSTANCE, + ProcessType.SINGLE_TOOL, + category), + units); + } + if (row == null) { + row = new PaygInstanceUsage(teamId, periodStart, category.name(), cumulative, syncSeq); + } else { + row.setLastCumulativeUnits(cumulative); + row.setLastSyncSeq(syncSeq); + } + usageRepository.save(row); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java new file mode 100644 index 0000000000..45a6435746 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java @@ -0,0 +1,74 @@ +package stirling.software.saas.payg.instance; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team, + * billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative + * unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via + * the standard charge path and advances this row. {@code lastSyncSeq} dedups replays. + */ +@Entity +@Table( + name = "payg_instance_usage", + uniqueConstraints = + @UniqueConstraint( + name = "uk_payg_instance_usage", + columnNames = {"team_id", "period_start", "category"})) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaygInstanceUsage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** {@code BillingCategory} name — API / AI / AUTOMATION. */ + @Column(name = "category", nullable = false, length = 32) + private String category; + + @Column(name = "last_cumulative_units", nullable = false) + private long lastCumulativeUnits; + + @Column(name = "last_sync_seq", nullable = false) + private long lastSyncSeq; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public PaygInstanceUsage( + Long teamId, + LocalDateTime periodStart, + String category, + long lastCumulativeUnits, + long lastSyncSeq) { + this.teamId = teamId; + this.periodStart = periodStart; + this.category = category; + this.lastCumulativeUnits = lastCumulativeUnits; + this.lastSyncSeq = lastSyncSeq; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java index 86e6e4ab47..4e62afcbd8 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java @@ -1,22 +1,18 @@ package stirling.software.saas.payg.lineage; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; import java.util.Set; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import stirling.software.proprietary.billing.ContentHasher; + /** * SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every - * content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read - * buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions). + * content type, doesn't parse. Delegates to the shared {@link ContentHasher} so the cloud charge + * path and a linked self-hosted instance's meter compute byte-identical signatures. * *

    Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code * PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed @@ -26,36 +22,15 @@ @Profile("saas") public class ByteHashSignatureExtractor implements LineageSignatureExtractor { - private static final String ALGORITHM = "SHA-256"; private static final String SIGNATURE_TYPE = "sha256"; - private static final int BUFFER_SIZE = 64 * 1024; @Override public Set extract(Path file) throws IOException { - MessageDigest digest = newDigest(); - try (InputStream raw = Files.newInputStream(file); - DigestInputStream in = new DigestInputStream(raw, digest)) { - byte[] buf = new byte[BUFFER_SIZE]; - // Drain through the digest stream; we only care about side effects on the digest. - while (in.read(buf) != -1) { - // no-op - } - } - String hex = HexFormat.of().formatHex(digest.digest()); - return Set.of(new LineageSignature(SIGNATURE_TYPE, hex)); + return Set.of(new LineageSignature(SIGNATURE_TYPE, ContentHasher.sha256(file))); } @Override public String name() { return SIGNATURE_TYPE; } - - private static MessageDigest newDigest() { - try { - return MessageDigest.getInstance(ALGORITHM); - } catch (NoSuchAlgorithmException e) { - // SHA-256 is mandated by every JDK; unreachable in practice. - throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e); - } - } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java index 8f7d2b3df7..b023f08fb5 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java @@ -15,5 +15,12 @@ public enum JobSource { /** * The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend. */ - DESKTOP_APP + DESKTOP_APP, + /** + * Usage reported by a linked self-hosted instance via the daily sync (combined-billing "Mode + * A"). The per-request surface is lost in the aggregate — the instance reports cumulative units + * per {@code BillingCategory} — so this just marks the charge as instance-synced. No per-source + * step limit is seeded for it; the charge path's fallback applies. + */ + LINKED_INSTANCE } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java new file mode 100644 index 0000000000..283cc212b1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java @@ -0,0 +1,35 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; + +import stirling.software.saas.payg.instance.PaygInstanceUsage; + +/** Last-seen cumulative usage per (team, period, category) for linked-instance daily syncs. */ +public interface PaygInstanceUsageRepository extends JpaRepository { + + Optional findByTeamIdAndPeriodStartAndCategory( + Long teamId, LocalDateTime periodStart, String category); + + /** + * Pessimistic-write variant the ingest uses so two concurrent deliveries of the same sync (e.g. + * a proxy retry) can't both read the same baseline and double-charge the delta. Must run inside + * a transaction. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + "SELECT u FROM PaygInstanceUsage u" + + " WHERE u.teamId = :teamId AND u.periodStart = :periodStart" + + " AND u.category = :category") + Optional findByTeamIdAndPeriodStartAndCategoryForUpdate( + @Param("teamId") Long teamId, + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category); +} diff --git a/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql new file mode 100644 index 0000000000..7ab65b8b78 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql @@ -0,0 +1,35 @@ +-- Twin of supabase/migrations/_payg_instance_usage.sql (Stirling-PDF-SaaS). Keep the table +-- definition byte-identical to the Supabase twin — both repos own this stirling_pdf table (the SaaS +-- profile runs this Flyway migration against the Supabase-backed DB; non-Hibernate consumers — RLS, +-- PostgREST, edge functions — rely on the Supabase migration ledger having the matching entry). +-- +-- Per-(team, billing period, category) last-seen cumulative usage reported by a linked self-hosted +-- instance (combined-billing "Mode A"). The instance reports monotonic cumulative unit totals on +-- its daily sync; SaaS bills the DELTA since the last sync — idempotent (a resend bills nothing) and +-- tamper-evident (a counter that drops is a signal) — by reusing the standard charge path +-- (JobChargeService.chargeStandalone), so no separate billing logic exists for this flow. +-- +-- Inert until release: written only by the InstanceController /sync endpoint, gated behind +-- stirling.billing.account-link.enabled (default off). Additive, idempotent table. + +CREATE TABLE IF NOT EXISTS stirling_pdf.payg_instance_usage ( + id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, + period_start TIMESTAMP NOT NULL, + category VARCHAR(32) NOT NULL, + -- Highest cumulative unit total seen for this (team, period, category); the next sync bills + -- (reported cumulative - this). + last_cumulative_units BIGINT NOT NULL DEFAULT 0, + -- Highest sync sequence applied; a sync at or below this is a replay and is ignored. + last_sync_seq BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_payg_instance_usage UNIQUE (team_id, period_start, category) +); + +CREATE INDEX IF NOT EXISTS idx_payg_instance_usage_team + ON stirling_pdf.payg_instance_usage (team_id); + +COMMENT ON TABLE stirling_pdf.payg_instance_usage IS + 'Last-seen cumulative usage per (team, billing period, category) reported by linked self-hosted ' + 'instances (combined-billing Mode A). SaaS bills the delta vs last_cumulative_units via the ' + 'standard charge path; last_sync_seq dedups replays.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql new file mode 100644 index 0000000000..f79097e325 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql @@ -0,0 +1,19 @@ +-- Twin of supabase/migrations/_payg_shadow_charge_linked_instance_source.sql (Stirling-PDF-SaaS). +-- Keep byte-identical to the Supabase twin. +-- +-- Widen the payg_shadow_charge.job_source CHECK to allow LINKED_INSTANCE (combined-billing "Mode +-- A"). A linked instance's daily-sync charge runs through JobChargeService.chargeStandalone, which +-- writes a payg_shadow_charge row with job_source=LINKED_INSTANCE — a JobSource value added after +-- the original constraint, so the insert was failing the check and 500ing POST /api/v1/instance/sync. +-- +-- Idempotent (DROP IF EXISTS + ADD, so it survives being applied by both the Flyway and Supabase +-- migration sets against the same schema) and additive (the new set is a superset of the JobSource +-- enum; the app only ever writes enum values, so no existing row can violate it). + +ALTER TABLE stirling_pdf.payg_shadow_charge + DROP CONSTRAINT IF EXISTS payg_shadow_charge_job_source_check; + +ALTER TABLE stirling_pdf.payg_shadow_charge + ADD CONSTRAINT payg_shadow_charge_job_source_check + CHECK (job_source IS NULL + OR job_source IN ('WEB', 'API', 'PIPELINE', 'DESKTOP_APP', 'LINKED_INSTANCE')); diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java index d221ac8603..4c9cc3b76a 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.saas.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -8,9 +9,12 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -19,14 +23,19 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.accountlink.InstanceController.EntitlementResponse; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; import stirling.software.saas.payg.entitlement.EntitlementService; import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.instance.InstanceUsageIngestService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.EntitlementState; import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; /** * Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read. @@ -39,9 +48,22 @@ class InstanceControllerTest { @Mock private EntitlementService entitlementService; @Mock private TeamBillingService billingService; @Mock private AccountLinkService accountLinkService; + @Mock private PricingPolicyService pricingPolicyService; + @Mock private InstanceUsageIngestService usageIngestService; + @Mock private LinkedInstanceRepository linkedInstanceRepository; private InstanceController controller() { - return new InstanceController(entitlementService, billingService, accountLinkService); + return new InstanceController( + entitlementService, + billingService, + accountLinkService, + pricingPolicyService, + usageIngestService, + linkedInstanceRepository); + } + + private static PricingPolicy policy() { + return new PricingPolicy(1, 1_048_576L, 1, 1000); } @Test @@ -50,6 +72,7 @@ void entitlement_resolvesTeamFromTokenAndMapsSnapshot() { when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L)); when(entitlementService.getSnapshot(42L)) .thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(42L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -62,6 +85,13 @@ void entitlement_resolvesTeamFromTokenAndMapsSnapshot() { assertThat(body.periodCapUnits()).isEqualTo(1250L); // WARNED is still within budget for the gate's purposes → coarse OK. assertThat(body.state()).isEqualTo("OK"); + // Phase 2: the metering inputs the instance needs ride along. + assertThat(body.unitCalcPolicy()).isEqualTo(new UnitCalcPolicy(1, 1_048_576L, 1, 1000)); + assertThat(body.periodStart()).isNotNull(); + assertThat(body.periodEnd()).isNotNull(); + // The instance-facing read drops the cached snapshot first so a just-subscribed team's + // plan surfaces on the next poll instead of waiting out the cache TTL. + verify(entitlementService).invalidate(42L); } @Test @@ -70,6 +100,7 @@ void entitlement_uncapped_returnsNullCapUnits() { when(billingService.forTeam(7L)).thenReturn(freeBilling(500L)); when(entitlementService.getSnapshot(7L)) .thenReturn(snapshot(EntitlementState.FULL, 0L, null)); + when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -89,6 +120,7 @@ void entitlement_degradedMapsToOverLimit() { when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L)); when(entitlementService.getSnapshot(8L)) .thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(8L)).thenReturn(policy()); EntitlementResponse body = controller().entitlement(token).getBody(); @@ -110,6 +142,59 @@ void entitlement_nonInstancePrincipalIsRejected() { verifyNoInteractions(entitlementService, billingService); } + @Test + void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() { + Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L); + LinkedInstance li = new LinkedInstance(); + li.setCreatedByUserId(7L); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(linkedInstanceRepository.findById(4L)).thenReturn(Optional.of(li)); + when(billingService.forTeam(99L)).thenReturn(freeBilling(10L)); + // The reported periodStart is validated against the authoritative snapshot period. + when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null)); + when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy()); + + InstanceController.UsageSyncRequest req = + new InstanceController.UsageSyncRequest( + 3L, + period, + new InstanceController.UsageSyncRequest.CategoryUnits(12, 4, 8)); + + ResponseEntity resp = controller().sync(token, req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + ArgumentCaptor> 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 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 @@ private static EntitlementSnapshot snapshot(EntitlementState state, long spend, 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); + } } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java index 8433f5e5a3..6cc97d916b 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -432,6 +432,52 @@ void updateCap_anonymousIs401() { 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 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 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 resp = controller.refreshWallet(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(entitlementService); + } + // ----------------------------------------------------------------------------------------- // Fixtures // ----------------------------------------------------------------------------------------- diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java index 8b3ce50b6a..e669e1b5bd 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java @@ -913,6 +913,52 @@ void chargeStandalone_bypassedCategory_throws() { .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 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 { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java new file mode 100644 index 0000000000..57c7b42c01 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java @@ -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 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 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); + } +} diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d07d8379da..346ece1523 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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." diff --git a/frontend/editor/src/portal/api/billing.ts b/frontend/editor/src/portal/api/billing.ts index fbb8673a26..3e76ab2989 100644 --- a/frontend/editor/src/portal/api/billing.ts +++ b/frontend/editor/src/portal/api/billing.ts @@ -21,6 +21,18 @@ export async function fetchWallet(): Promise { return apiClient.saas.json("/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 { + await apiClient.saas.json("/api/v1/payg/wallet/refresh", { + method: "POST", + }); +} + // ──────────────────────────────────────────────────────────────────────────── // Cap — leader-only PATCH (real endpoint). // ──────────────────────────────────────────────────────────────────────────── diff --git a/frontend/editor/src/portal/api/link.test.ts b/frontend/editor/src/portal/api/link.test.ts index 2f5260486a..e03d5bf25b 100644 --- a/frontend/editor/src/portal/api/link.test.ts +++ b/frontend/editor/src/portal/api/link.test.ts @@ -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 }) => { diff --git a/frontend/editor/src/portal/api/link.ts b/frontend/editor/src/portal/api/link.ts index c55978dffc..9b4876ac8d 100644 --- a/frontend/editor/src/portal/api/link.ts +++ b/frontend/editor/src/portal/api/link.ts @@ -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 { return apiClient.local.json(`${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 { + return apiClient.local.json(`${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 { await apiClient.local.json(`${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 { + await apiClient.local.json(`${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 diff --git a/frontend/editor/src/portal/billing/stripe.ts b/frontend/editor/src/portal/billing/stripe.ts index 87fa82cf7b..91e0290d72 100644 --- a/frontend/editor/src/portal/billing/stripe.ts +++ b/frontend/editor/src/portal/billing/stripe.ts @@ -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 } : {}), diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx index 0a1ae5216a..7132e6dfa1 100644 --- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -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; } 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(null); @@ -86,7 +93,11 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { {/* Processor trial — meter with the inline upgrade CTA */} - + {missingTeam && ( setModalOpen(false)} teamId={wallet.teamId} currency={currency} - onComplete={() => { - setModalOpen(false); - onSubscribed?.(); - }} + onComplete={() => onSubscribed?.() ?? Promise.resolve(false)} /> )} diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx index eaf3172bec..6f4c46641c 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx @@ -14,6 +14,20 @@ type Story = StoryObj; /** 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: { diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx index aec101ea83..00d9806e6a 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx @@ -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. + * + *

    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 ( @@ -58,7 +79,7 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {

    - {wallet.billableUsed.toLocaleString()} + {headline.toLocaleString()} {t("portal.billing.pdfsProcessed.unit", "metered PDFs")} diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx index 313694baa9..2b8048ebcc 100644 --- a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx @@ -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; } /** @@ -45,6 +49,55 @@ function loadStripeOnce(pk: string): Promise { return stripePromise; } +/** Payment done, waiting for the subscription webhook to activate the plan. */ +function CheckoutFinalizing() { + const { t } = useTranslation(); + return ( +
    + +

    + {t( + "portal.billing.checkout.finalizing.title", + "Activating your Processor plan...", + )} +

    +

    + {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.", + )} +

    +

    + {t( + "portal.billing.checkout.finalizing.hint", + "Please keep this window open.", + )} +

    +
    + ); +} + +/** Webhook lagging past the poll window — dismissable "it'll appear shortly" notice. */ +function CheckoutActivationSlow({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + return ( +
    +

    + {t("portal.billing.checkout.activationSlow.title", "Almost there")} +

    +

    + {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.", + )} +

    + +
    + ); +} + export function StripeCheckoutModal({ open, onClose, @@ -57,16 +110,29 @@ export function StripeCheckoutModal({ const [clientSecret, setClientSecret] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 ( - {!publishableKey && ( - } + + {phase === "activationSlow" && ( + + )} + + {phase === "checkout" && ( + <> + {!publishableKey && ( + + {t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "} + VITE_STRIPE_PUBLISHABLE_KEY{" "} + {t( + "portal.billing.checkout.notConfigured.bodyAfter", + "in the portal env to enable in-app checkout.", + )} + )} - > - {t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "} - VITE_STRIPE_PUBLISHABLE_KEY{" "} - {t( - "portal.billing.checkout.notConfigured.bodyAfter", - "in the portal env to enable in-app checkout.", + {publishableKey && error && ( + + {error} + )} - - )} - {publishableKey && error && ( - + + +
    )} - > - {error} - - )} - {publishableKey && loading && !error && ( -
    - - -
    - )} - {publishableKey && canRender && stripe && clientSecret && ( - - - + {publishableKey && canRender && stripe && clientSecret && ( + + + + )} + )} ); diff --git a/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx b/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx index 37bf35a54a..4d92eecf77 100644 --- a/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx +++ b/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx @@ -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) { - +
    diff --git a/frontend/editor/src/portal/components/billing/WalletMeter.tsx b/frontend/editor/src/portal/components/billing/WalletMeter.tsx index 1cb7f5794f..081f6b2a32 100644 --- a/frontend/editor/src/portal/components/billing/WalletMeter.tsx +++ b/frontend/editor/src/portal/components/billing/WalletMeter.tsx @@ -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. + * + *

    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) { diff --git a/frontend/editor/src/portal/components/billing/billing.css b/frontend/editor/src/portal/components/billing/billing.css index ee1f593833..63ea82d748 100644 --- a/frontend/editor/src/portal/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -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; diff --git a/frontend/editor/src/portal/mocks/handlers/link.ts b/frontend/editor/src/portal/mocks/handlers/link.ts index 2be110a794..6ac6781f71 100644 --- a/frontend/editor/src/portal/mocks/handlers/link.ts +++ b/frontend/editor/src/portal/mocks/handlers/link.ts @@ -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 diff --git a/frontend/editor/src/portal/mocks/link.ts b/frontend/editor/src/portal/mocks/link.ts index f540f85177..0dd11e104c 100644 --- a/frontend/editor/src/portal/mocks/link.ts +++ b/frontend/editor/src/portal/mocks/link.ts @@ -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. */ diff --git a/frontend/editor/src/portal/views/Usage.tsx b/frontend/editor/src/portal/views/Usage.tsx index 3a0229a886..d1f6ede78a 100644 --- a/frontend/editor/src/portal/views/Usage.tsx +++ b/frontend/editor/src/portal/views/Usage.tsx @@ -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(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(null); const [loading, setLoading] = useState(isLinked); const [error, setError] = useState(null); // The instance is linked but the browser's SaaS session has lapsed — needs a // re-sign-in, NOT a re-link. const [needsReauth, setNeedsReauth] = useState(false); - // Briefly polling the wallet after a successful checkout until the webhook flips - // it to subscribed. - const [finalizing, setFinalizing] = useState(false); const [refreshKey, setRefreshKey] = useState(0); // Stripe customer portal — the subscribed header's "Manage Payment" action. const portal = useStripePortal(wallet); @@ -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 => { // 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() {

    )} - {isLinked && finalizing && ( - - {t( - "portal.usage.finalizing.body", - "It can take a few seconds for your subscription to activate. This page updates automatically.", - )} - - )} - {isLinked && needsReauth && ( )} - {isLinked && !finalizing && wallet && wallet.status === "free" && ( - + {isLinked && wallet && wallet.status === "free" && ( + )} {isLinked && wallet && wallet.status === "subscribed" && ( - + )}