From f201aa59152b7e79f2d74a633ea160580b3e6eef Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:39:24 +0100 Subject: [PATCH] =?UTF-8?q?feat(account-link):=20Phase=202=20=E2=80=94=20i?= =?UTF-8?q?nstance=20metering=20+=20daily=20usage=20sync=20(#6839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Account-link Phase 2: metering + daily usage sync Phase 1 (already on main) let a self-hosted instance link a SaaS account and blocked billable work when it was over its limit. It blocked, but it never actually charged anything. This PR adds the metering + billing half. **It's off by default.** Everything sits behind `stirling.billing.account-link.metering.enabled`, on top of the existing `stirling.billing.account-link.enabled` master flag. Both have to be on for any of it to run, so it can't touch production. The billing model isn't going live yet — this is a dark merge. ### How it works 1. The instance classifies each billable request (API / AI / Automation — manual PDF editing stays free) and counts it locally into a per-period counter. 2. Once a day it reports its running totals to SaaS. 3. SaaS bills only the delta since the last report, reusing the existing charge path (free grant + wallet ledger + Stripe meter). No new money logic. 4. The portal shows current usage (synced spend plus anything not reported yet), and when you subscribe it now reflects the new plan right away instead of waiting for a cache to expire. ### What's worth a reviewer's eyes - **It can't double-charge.** SaaS only ever bills the delta, refuses a counter that goes backwards, dedups repeat/late reports on a monotonic sequence number, and takes a row lock so a duplicate delivery can't charge twice. - **The cap is enforced at the instance gate**, not in the charge path (same as the in-cloud flow). A $0 cap blocks all metered work. - Page counts use jpdfium so the instance and the cloud agree on the number that gets billed. - New SaaS surface: `POST /api/v1/instance/sync`, migrations V25 (`payg_instance_usage`) and V26 (allow the `LINKED_INSTANCE` job source), and a small `POST /api/v1/payg/wallet/refresh` the portal calls after checkout. ### Companion PR Stirling-PDF-SaaS #314 (on `v3`): the checkout edge function so the embedded Stripe flow finishes in-page instead of reloading, plus a `Deno.serve` migration so the edge functions actually deploy. ### Testing Java unit tests (proprietary + saas), portal vitest, and the SaaS edge-function tests all pass. Branch is merged up to date with main. ### Not done yet (doesn't block this merge — only matters once both flags are on) - V25 Supabase twin in the SaaS repo. - Same in-page checkout fix for the editor's upgrade modal. - A flags-on smoke test in staging (one real sync round-trip). --------- Co-authored-by: James Brunton --- .../accountlink/AccountLinkClient.java | 128 +++++++++-- .../accountlink/AccountLinkController.java | 40 +++- .../accountlink/AccountLinkProperties.java | 37 +++ .../accountlink/AccountLinkSyncState.java | 46 ++++ .../AccountLinkSyncStateRepository.java | 6 + .../BillableOperationClassifier.java | 38 +++- .../accountlink/EntitlementCache.java | 51 +++-- .../proprietary/accountlink/GateDecision.java | 5 + .../accountlink/InstanceEntitlement.java | 42 +++- .../accountlink/InstanceEntitlementGate.java | 102 +++++++-- .../InstanceEntitlementInterceptor.java | 202 ++++++++++++++++- .../accountlink/LocalUsageService.java | 59 +++++ .../accountlink/MeteredInputSignature.java | 73 ++++++ .../MeteredInputSignatureRepository.java | 15 ++ .../proprietary/accountlink/UsageCounter.java | 105 +++++++++ .../accountlink/UsageCounterRepository.java | 57 +++++ .../accountlink/UsageMeterService.java | 115 ++++++++++ .../accountlink/UsageSyncService.java | 189 ++++++++++++++++ .../proprietary/billing/BillingCategory.java | 22 ++ .../billing/BillingCategoryClassifier.java | 29 +++ .../proprietary/billing/ContentHasher.java | 68 ++++++ .../billing/DocumentUnitCalculator.java | 76 +++++++ .../proprietary/billing/UnitCalcPolicy.java | 30 +++ .../accountlink/AccountLinkClientTest.java | 70 ++++++ .../AccountLinkControllerTest.java | 31 ++- .../BillableOperationClassifierTest.java | 75 +++++-- .../InstanceEntitlementGateTest.java | 212 ++++++++++++++++-- .../InstanceEntitlementGateWiringTest.java | 14 +- .../InstanceEntitlementInterceptorTest.java | 114 +++++++++- .../accountlink/LocalUsageServiceTest.java | 75 +++++++ .../accountlink/UsageMeterServiceTest.java | 133 +++++++++++ .../accountlink/UsageSyncServiceTest.java | 171 ++++++++++++++ .../BillingCategoryClassifierTest.java | 30 +++ .../saas/accountlink/InstanceController.java | 124 +++++++++- .../saas/payg/api/PaygWalletController.java | 27 +++ .../payg/docs/DefaultDocumentClassifier.java | 53 ++--- .../instance/InstanceUsageIngestService.java | 130 +++++++++++ .../saas/payg/instance/PaygInstanceUsage.java | 74 ++++++ .../lineage/ByteHashSignatureExtractor.java | 35 +-- .../software/saas/payg/model/JobSource.java | 9 +- .../PaygInstanceUsageRepository.java | 35 +++ .../saas/V25__payg_instance_usage.sql | 35 +++ ...g_shadow_charge_linked_instance_source.sql | 19 ++ .../accountlink/InstanceControllerTest.java | 100 ++++++++- .../payg/api/PaygWalletControllerTest.java | 46 ++++ .../payg/charge/JobChargeServiceTest.java | 46 ++++ .../InstanceUsageIngestServiceTest.java | 135 +++++++++++ .../public/locales/en-US/translation.toml | 14 +- frontend/editor/src/portal/api/billing.ts | 12 + frontend/editor/src/portal/api/link.test.ts | 11 + frontend/editor/src/portal/api/link.ts | 22 ++ frontend/editor/src/portal/billing/stripe.ts | 5 + .../components/billing/FreePlanView.tsx | 24 +- .../billing/PdfsProcessedCard.stories.tsx | 14 ++ .../components/billing/PdfsProcessedCard.tsx | 27 ++- .../billing/StripeCheckoutModal.tsx | 192 ++++++++++++---- .../components/billing/SubscribedPlanView.tsx | 11 +- .../portal/components/billing/WalletMeter.tsx | 21 +- .../src/portal/components/billing/billing.css | 41 ++++ .../editor/src/portal/mocks/handlers/link.ts | 14 ++ frontend/editor/src/portal/mocks/link.ts | 34 +++ frontend/editor/src/portal/views/Usage.tsx | 86 ++++--- 62 files changed, 3552 insertions(+), 304 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java 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.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.time.LocalDateTime; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -14,25 +15,31 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.UnitCalcPolicy; + import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; /** * Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode * A"). * - *

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

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 @@ import lombok.extern.slf4j.Slf4j; public class AccountLinkController { private final AccountLinkService service; + private final LocalUsageService localUsageService; + // Present only when metering is on (its own flag); absent → /sync-now reports 409. + private final ObjectProvider 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 class AccountLinkController { service.unlink(); return ResponseEntity.noContent().build(); } + + /** + * Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend + * so "current usage" includes work done since the last daily sync. + */ + @GetMapping("/usage") + public ResponseEntity 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 @@ package stirling.software.proprietary.accountlink; import jakarta.servlet.http.HttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.BillingCategoryClassifier; /** - * Classifies a request as 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 org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; /** - * Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on - * every billable request. Single-slot (one instance = one linked team), TTL-based. + * Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable + * request. Single-slot (one instance = one linked team), TTL-based. * - *

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 class 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 class EntitlementCache { } private boolean isStale(Snapshot snap) { - // fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed - // fetch backs off for a full TTL instead of every billable request re-triggering a - // blocking round-trip against a dead/slow SaaS endpoint. + // fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch + // backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS. return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0; } /** - * Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but - * still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny - * (revoked credential) replaces it with a blocked snapshot so the gate stops billable work. + * Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the + * attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a + * blocked snapshot. */ void refresh() { Optional cred = credentialStore.get(); @@ -101,15 +94,15 @@ public class EntitlementCache { if (fresh != null) { snapshot = new Snapshot(fresh, Instant.now()); } else { - // Unreachable / server error: keep the last known entitlement (may be null) but - // stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime. + // Unreachable / server error: keep the last known entitlement but stamp the attempt + // so we don't hammer SaaS; the gate fails open meanwhile. log.debug( "Entitlement refresh failed; reusing last known snapshot, backing off a TTL"); snapshot = new Snapshot(snapshot.entitlement(), Instant.now()); } } catch (AccountLinkClient.RevokedException e) { - // Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately - // rather than serving the stale entitled snapshot until the next unlink. + // Authoritative deny — block immediately rather than serving the stale entitled + // snapshot. log.info( "Entitlement denied (HTTP {}); blocking billable work for the revoked credential", e.status()); @@ -121,4 +114,14 @@ public class EntitlementCache { public void invalidate() { snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH); } + + /** + * Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh + * one), saving a redundant fetch. No-op on null. + */ + public void accept(InstanceEntitlement fresh) { + if (fresh != null) { + snapshot = new Snapshot(fresh, Instant.now()); + } + } } 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 record GateDecision(boolean allowed, Reason reason) { ENTITLED, /** Entitlement source unreachable — fail open, allow. */ FAIL_OPEN, + /** + * Linked + metering, but SaaS has been unreachable past the grace window — block (the + * fail-open backstop expired) so unbounded free/unbilled billable work can't continue. + */ + GRACE_EXPIRED, /** Not linked — block billable work; FE should prompt to link. */ NOT_LINKED, /** Linked but over the limit / no subscription — block billable work. */ 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 @@ import org.springframework.stereotype.Service; *

  • 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 class InstanceEntitlementGate { 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 class InstanceEntitlementGate { return GateDecision.block(GateDecision.Reason.NOT_LINKED); } if (entitlement.isEmpty()) { - // Linked but entitlement source unreachable — never hard-block billable work on our - // inability to reach billing. - return GateDecision.allow(GateDecision.Reason.FAIL_OPEN); + // Linked but entitlement unreachable: fail open, unless the grace window has expired + // (so + // the fail-open can't grant unbounded unbilled work forever). + return graceExpired + ? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED) + : GateDecision.allow(GateDecision.Reason.FAIL_OPEN); } InstanceEntitlement e = entitlement.get(); if (e.state() == EntitlementState.REVOKED) { // Credential revoked/invalid (authoritative deny) — block, distinct from over-limit. return GateDecision.block(GateDecision.Reason.REVOKED); } - return entitled(e) + return entitled(e, pendingUnsyncedUnits) ? GateDecision.allow(GateDecision.Reason.ENTITLED) : GateDecision.block(GateDecision.Reason.OVER_LIMIT); } + /** + * True when metering is on and it's been {@code graceDays} since the last authoritative contact + * (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off + * disables the backstop. + */ + private boolean isGraceExpired() { + AccountLinkProperties.Metering metering = properties.getMetering(); + if (!metering.isEnabled() || metering.getGraceDays() <= 0) { + return false; + } + LocalDateTime reference = lastAuthoritativeContact(); + if (reference == null) { + return false; // can't determine elapsed time → fail open + } + return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now()); + } + + private LocalDateTime lastAuthoritativeContact() { + LocalDateTime lastSuccess = + syncStateRepository + .findById(AccountLinkSyncState.SINGLETON_ID) + .map(AccountLinkSyncState::getLastSuccessAt) + .orElse(null); + if (lastSuccess != null) { + return lastSuccess; + } + return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null); + } + /** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */ - private static boolean entitled(InstanceEntitlement e) { + private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) { if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) { return false; } if (e.subscribed()) { - // Subscribed: allowed unless a period cap is set and exceeded. - return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits(); + if (e.periodCapUnits() == null) { + return true; // uncapped subscription + } + // Project the cap the way the grant is projected: synced paid spend plus the paid part + // of local usage not yet synced (free grant is consumed first, so only the excess + // bills) — stops at the cap in real time instead of overshooting until the next sync. + long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits()); + return e.periodSpendUnits() + pendingPaid < e.periodCapUnits(); } - // Unsubscribed: only the free pool covers billable work. - return e.freeRemainingUnits() > 0; + // Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local + // usage not yet synced — deplete by the pending delta so we stop at the grant in real time. + return e.freeRemainingUnits() - pendingUnsyncedUnits > 0; } } 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 @@ import lombok.extern.slf4j.Slf4j; @ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class InstanceEntitlementInterceptor implements HandlerInterceptor { - private final InstanceEntitlementGate gate; + private static final String ATTR_CATEGORY = + InstanceEntitlementInterceptor.class.getName() + ".category"; - public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) { + private final InstanceEntitlementGate gate; + private final EntitlementCache entitlementCache; + private final ObjectProvider meterProvider; + private final TempFileManager tempFileManager; + + 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 class InstanceEntitlementInterceptor implements HandlerInterceptor { throws Exception { GateDecision decision; try { - decision = gate.evaluate(BillableOperationClassifier.isBillable(request)); + // API-key tool calls are billable (category API); stash the category for the meter. + boolean apiKey = + SecurityContextHolder.getContext().getAuthentication() + instanceof ApiKeyAuthenticationToken; + BillingCategory category = BillableOperationClassifier.categorize(request, apiKey); + request.setAttribute(ATTR_CATEGORY, category); + decision = gate.evaluate(category != BillingCategory.BYPASSED); } catch (RuntimeException e) { // Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never // turn into a hard block on billable work. @@ -62,4 +105,139 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { + "\"}"); return false; } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + // Meter successful billable ops only. + if (ex != null || response.getStatus() >= 400) { + return; + } + UsageMeterService meter = meterProvider.getIfAvailable(); + if (meter == null) { + return; // metering switch off + } + if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category) + || category == BillingCategory.BYPASSED) { + return; + } + try { + InstanceEntitlement ent = entitlementCache.current().orElse(null); + if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) { + // Not yet synced (no policy/period) — can't compute units; skip until next sync. + return; + } + meterRequest(request, category, ent, meter); + } catch (RuntimeException e) { + // Metering must never affect the response that already completed. + log.debug("Usage metering failed for {}", request.getRequestURI(), e); + } + } + + /** + * Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance + * is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy + * but small PDF must be page-counted or it under-bills. A fileless op has no input identity — + * null signature (no dedup), billed the 1-unit floor each time. + */ + private void meterRequest( + HttpServletRequest request, + BillingCategory category, + InstanceEntitlement ent, + UsageMeterService meter) { + UnitCalcPolicy policy = ent.unitCalcPolicy(); + MultipartHttpServletRequest mreq = + WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); + if (mreq == null) { + long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy); + meter.accrue(ent.periodStart(), category, fileless, null); + return; + } + List 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.ConnectException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -189,4 +190,73 @@ class AccountLinkClientTest { .thenThrow(new ConnectException("refused")); assertEquals(false, client.revokeSelf("dev-1", "sec-1")); } + + @Test + @SuppressWarnings("unchecked") + void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception { + HttpResponse 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 @@ package stirling.software.proprietary.accountlink; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -21,12 +24,18 @@ import stirling.software.proprietary.accountlink.AccountLinkController.LinkReque class AccountLinkControllerTest { private AccountLinkService service; + private UsageSyncService syncService; + private ObjectProvider syncProvider; private AccountLinkController controller; @BeforeEach + @SuppressWarnings("unchecked") void setUp() { service = mock(AccountLinkService.class); - controller = new AccountLinkController(service); + syncService = mock(UsageSyncService.class); + syncProvider = mock(ObjectProvider.class); + controller = + new AccountLinkController(service, mock(LocalUsageService.class), syncProvider); } @Test @@ -65,4 +74,24 @@ class AccountLinkControllerTest { ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); } + + @Test + void syncNow_triggersSyncWhenMeteringOn() { + when(syncProvider.getIfAvailable()).thenReturn(syncService); + + ResponseEntity 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 { - @Test - void aiPathIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo"); - assertTrue(BillableOperationClassifier.isBillable(req)); + private static MockHttpServletRequest req(String uri) { + return new MockHttpServletRequest("POST", uri); } @Test - void automationHeaderIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + void aiPathIsAi() { + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false)); + } + + @Test + void automationHeaderIsAutomation() { + MockHttpServletRequest req = req("/api/v1/general/merge"); req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals( + BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false)); } @Test - void plainManualToolIsFree() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void apiKeyToolCallIsApi() { + assertEquals( + BillingCategory.API, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true)); } @Test - void aiSegmentNotAtPathStartIsFree() { - // Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a - // proxy prefix) must NOT classify a manual tool as billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void plainManualToolIsBypassed() { + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false)); } @Test - void aiPathUnderContextPathIsBillable() { - // A real context-path deployment still classifies: //api/v1/ai/** is billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo"); + void automationDominatesAiAndApiKey() { + // An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION. + MockHttpServletRequest req = req("/api/v1/ai/tools/foo"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true"); + assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true)); + } + + @Test + void aiDominatesApiKey() { + // A direct API-key call to an AI tool bills as AI, not API. + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true)); + } + + @Test + void aiSegmentNotAtPathStartIsBypassed() { + // Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix) + // must NOT classify a manual tool as AI. + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false)); + } + + @Test + void aiPathUnderContextPathIsAi() { + // A real context-path deployment still classifies: //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 @@ package stirling.software.proprietary.accountlink; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; +import java.time.LocalDateTime; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.accountlink.GateDecision.Reason; /** - * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and - * over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is - * needed. + * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired, + * linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no + * Spring; the grace-window reference computation is exercised through {@link + * InstanceEntitlementGate#evaluate} with mocked collaborators. */ +@ExtendWith(MockitoExtension.class) class InstanceEntitlementGateTest { + @Mock private DeviceCredentialStore credentialStore; + @Mock private EntitlementCache entitlementCache; + @Mock private AccountLinkSyncStateRepository syncStateRepository; + @Mock private LocalUsageService localUsageService; + private static InstanceEntitlement free() { return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK); } @@ -35,35 +47,67 @@ class InstanceEntitlementGateTest { @Test void flagOff_allowsEverything_evenBillableUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FLAG_OFF, d.reason()); } @Test void manualTool_alwaysFree_evenUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.MANUAL_FREE, d.reason()); } @Test void billable_notLinked_blocksWithLinkSignal() { - GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(true, true, false, Optional.empty(), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.NOT_LINKED, d.reason()); } @Test - void billable_linked_entitlementUnreachable_failsOpen() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty()); + void billable_linked_entitlementUnreachable_withinGrace_failsOpen() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FAIL_OPEN, d.reason()); } + @Test + void billable_linked_entitlementUnreachable_graceExpired_blocks() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L); + assertFalse(d.allowed()); + assertEquals(Reason.GRACE_EXPIRED, d.reason()); + } + @Test void billable_linked_freePoolAvailable_allows() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free())); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 0L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_unsubscribed_pendingLocalUsageDepletesGrant_blocks() { + // free() has 100 free units left per the last sync; 100 accrued locally since would exhaust + // it once charged, so the gate stops here in real time rather than waiting for the sync. + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 100L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_unsubscribed_pendingLocalUsageLeavesRoom_allows() { + // 99 pending against 100 remaining → one unit of grant still projected free → allow. + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 99L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -72,7 +116,7 @@ class InstanceEntitlementGateTest { void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(exhaustedUnsubscribed())); + true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -81,7 +125,7 @@ class InstanceEntitlementGateTest { void billable_linked_subscribedWithinCap_allows() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(subscribedWithinCap())); + true, true, true, Optional.of(subscribedWithinCap()), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -89,18 +133,67 @@ class InstanceEntitlementGateTest { @Test void billable_linked_subscribedOverCap_blocks() { GateDecision d = - InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap())); + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedOverCap()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } + @Test + void billable_linked_subscribedCapped_pendingLocalUsageWouldExceedCap_blocks() { + // Within cap per the last sync (spend 10 / cap 100), but 95 accrued locally since would + // push + // projected spend to 105 → the gate stops now, not after the next sync reconciles. + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap()), false, 95L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_subscribedCapped_pendingLeavesCapRoom_allows() { + // 10 synced + 80 pending = 90 < 100 cap → still room. + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap()), false, 80L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedCapped_freeGrantAbsorbsPending_allows() { + // 50 free units remain, so 40 pending is entirely free → 0 projected paid < 100 cap → + // allow. + InstanceEntitlement subscribedWithGrant = + new InstanceEntitlement(true, 50, 0, 100L, EntitlementState.OK); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithGrant), false, 40L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedUncapped_pendingIgnored_allows() { + // No cap → local pending has no ceiling to hit → always allowed. + InstanceEntitlement uncapped = + new InstanceEntitlement(true, 0, 999, null, EntitlementState.OK); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(uncapped), false, 500L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + @Test void billable_linked_revoked_blocksWithRevokedSignal() { // Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED — // blocks distinctly from over-limit, even though the snapshot is "present". InstanceEntitlement revoked = new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked)); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.REVOKED, d.reason()); } @@ -110,7 +203,98 @@ class InstanceEntitlementGateTest { // Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive. InstanceEntitlement conflicting = new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting)); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(conflicting), false, 0L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + // --- grace window (evaluate()) --------------------------------------------------------------- + + private InstanceEntitlementGate gate(AccountLinkProperties props) { + return new InstanceEntitlementGate( + props, credentialStore, entitlementCache, syncStateRepository, localUsageService); + } + + private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) { + AccountLinkProperties p = new AccountLinkProperties(); + p.setEnabled(true); + p.getMetering().setEnabled(meteringEnabled); + p.getMetering().setGraceDays(graceDays); + return p; + } + + @Test + void evaluate_meteringOff_unreachable_failsOpen_neverGraceBlocks() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + + GateDecision d = gate(props(false, 3)).evaluate(true); + + // Metering off → grace never applies, even if a sync is ancient. + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void evaluate_neverSynced_pastGraceSinceLink_blocks() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + .thenReturn(Optional.empty()); + DeviceCredential cred = new DeviceCredential(); + cred.setLinkedAt(LocalDateTime.now().minusDays(5)); + when(credentialStore.get()).thenReturn(Optional.of(cred)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertFalse(d.allowed()); + assertEquals(Reason.GRACE_EXPIRED, d.reason()); + } + + @Test + void evaluate_recentSync_withinGrace_failsOpen() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setLastSuccessAt(LocalDateTime.now().minusDays(1)); + when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + .thenReturn(Optional.of(state)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void evaluate_unsubscribed_localUsageWouldExceedGrant_blocksInRealTime() { + // 100 free units remaining per the last sync, but 100 already accrued locally since — the + // gate subtracts the pending delta and blocks now, not after the next sync reconciles. + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.of(free())); + when(localUsageService.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 100, 0, 0, 100)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void evaluate_subscribedCapped_localUsageWouldExceedCap_blocksInRealTime() { + // Subscribed within cap per the last sync (spend 10 / cap 100), but 90 accrued locally + // since — evaluate() now depletes the cap by pending usage for capped subscriptions too, so + // the gate stops now instead of overshooting the cap until the next sync. + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.of(subscribedWithinCap())); + when(localUsageService.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 0, 90, 0, 90)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } 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 @@ class InstanceEntitlementGateWiringTest { properties.setEnabled(true); store = mock(DeviceCredentialStore.class); cache = mock(EntitlementCache.class); - gate = new InstanceEntitlementGate(properties, store, cache); + localUsage = mock(LocalUsageService.class); + gate = + new InstanceEntitlementGate( + properties, + store, + cache, + mock(AccountLinkSyncStateRepository.class), + localUsage); } @Test @@ -55,6 +63,10 @@ class InstanceEntitlementGateWiringTest { .thenReturn( Optional.of( new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK))); + // Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time; + // nothing pending here, so the 5 free units still allow the request. + when(localUsage.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0)); GateDecision d = gate.evaluate(true); assertTrue(d.allowed()); assertEquals(GateDecision.Reason.ENTITLED, d.reason()); 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 @@ package stirling.software.proprietary.accountlink; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockMultipartHttpServletRequest; + +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.UnitCalcPolicy; @ExtendWith(MockitoExtension.class) class InstanceEntitlementInterceptorTest { @Mock private InstanceEntitlementGate gate; + @Mock private EntitlementCache entitlementCache; + @Mock private ObjectProvider meterProvider; + @Mock private TempFileManager tempFileManager; + + private InstanceEntitlementInterceptor interceptor() { + return new InstanceEntitlementInterceptor( + gate, entitlementCache, meterProvider, tempFileManager); + } private boolean preHandle(MockHttpServletResponse response) throws Exception { - return new InstanceEntitlementInterceptor(gate) + return interceptor() .preHandle( new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object()); } @@ -58,4 +89,85 @@ class InstanceEntitlementInterceptorTest { assertTrue(preHandle(response)); assertEquals(200, response.getStatus()); } + + @Test + void metersSuccessfulBillableOp() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); // stashes AI category + interceptor.afterCompletion(req, resp, new Object(), null); + + // No uploaded files → bytes axis → the 1-unit floor; no input identity → null signature. + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L), isNull()); + } + + @Test + void metersPdfByPageCountNotJustBytes(@TempDir Path tmp) throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + // docPagesPerUnit=1, docBytesPerUnit=1MB → a tiny 5-page PDF costs 5 on the page axis but + // only 1 on the byte axis: page-counting (via jpdfium) is what makes this bill correctly. + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); + // Materialise to a real path under @TempDir; the interceptor writes the upload there and + // jpdfium + the hasher read it back. + TempFile temp = mock(TempFile.class); + when(temp.getPath()).thenReturn(tmp.resolve("input.bin")); + when(tempFileManager.createManagedTempFile(any())).thenReturn(temp); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest(); + req.setRequestURI("/api/v1/ai/x"); + req.addFile(new MockMultipartFile("file", "doc.pdf", "application/pdf", fivePagePdf())); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + // 5 pages + a non-null input-set signature (file ops carry a dedup key). + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L), notNull()); + } + + @Test + void doesNotMeterWhenMeteringSwitchOff() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + when(meterProvider.getIfAvailable()).thenReturn(null); // metering.enabled = false + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + // Meter absent → no entitlement lookup, no accrual. + verifyNoInteractions(entitlementCache); + } + + private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) { + return new InstanceEntitlement( + true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1)); + } + + private static byte[] fivePagePdf() throws Exception { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + for (int i = 0; i < 5; i++) { + doc.addPage(new PDPage()); + } + doc.save(out); + return out.toByteArray(); + } + } } 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.security.core.Authentication; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -16,11 +20,16 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; import stirling.software.saas.payg.entitlement.EntitlementService; import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.instance.InstanceUsageIngestService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; /** * Instance-facing surface (combined-billing "Mode A"), authenticated by the device @@ -46,14 +55,23 @@ public class InstanceController { private final EntitlementService entitlementService; private final TeamBillingService billingService; private final AccountLinkService accountLinkService; + private final PricingPolicyService pricingPolicyService; + private final InstanceUsageIngestService usageIngestService; + private final LinkedInstanceRepository linkedInstanceRepository; public InstanceController( EntitlementService entitlementService, TeamBillingService billingService, - AccountLinkService accountLinkService) { + AccountLinkService accountLinkService, + PricingPolicyService pricingPolicyService, + InstanceUsageIngestService usageIngestService, + LinkedInstanceRepository linkedInstanceRepository) { this.entitlementService = entitlementService; this.billingService = billingService; this.accountLinkService = accountLinkService; + this.pricingPolicyService = pricingPolicyService; + this.usageIngestService = usageIngestService; + this.linkedInstanceRepository = linkedInstanceRepository; } public record WhoAmIResponse(Long instanceId, Long teamId) {} @@ -68,7 +86,13 @@ public class InstanceController { long freeRemainingUnits, long periodSpendUnits, Long periodCapUnits, - String state) {} + String state, + // Metering inputs the instance needs to cost + bucket its own usage (Phase 2). The + // instance computes units locally with this policy and resets its per-period cumulative + // counters on the [periodStart, periodEnd) boundary. + UnitCalcPolicy unitCalcPolicy, + LocalDateTime periodStart, + LocalDateTime periodEnd) {} @GetMapping("/whoami") @PreAuthorize("hasRole('LINKED_INSTANCE')") @@ -103,20 +127,96 @@ public class InstanceController { if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - Long teamId = token.getTeamId(); + // Drop the cached snapshot first: this low-frequency read gates real-time billable work, so + // it must reflect a just-changed subscription/cap at once (the flip is a DB-function write + // with no Java event to invalidate on). + entitlementService.invalidate(token.getTeamId()); + return ResponseEntity.ok(buildEntitlement(token.getTeamId())); + } + /** Body for {@code POST /sync}: the instance's cumulative units per category this period. */ + public record UsageSyncRequest( + long syncSeq, LocalDateTime periodStart, CategoryUnits cumulativeUnits) { + public record CategoryUnits(long api, long ai, long automation) {} + } + + /** + * Daily usage sync: the instance reports its cumulative per-category unit totals for the + * period; SaaS bills the delta since the last sync (reusing the standard charge path) and + * returns the fresh entitlement — so one round-trip both reports usage and refreshes the gate + * state. + */ + @PostMapping("/sync") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + @Transactional + public ResponseEntity 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.security.core.Authentication; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -328,6 +329,32 @@ public class PaygWalletController { /** Request body for {@link #updateCap}. */ public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {} + // --------------------------------------------------------------------------------------- + // POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh + // --------------------------------------------------------------------------------------- + + /** + * Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a + * billing state that just changed out-of-band. The subscription flip is written by a Postgres + * function ({@code payg_link_subscription}) with no Java event to invalidate on, so a client + * that knows a change just happened — the portal while finalizing a checkout — pokes the cache + * here rather than waiting out the ~30s TTL. Team-scoped to the caller: a client can only + * refresh its own team, and a no-team caller is a cheap no-op. + */ + @PostMapping("/wallet/refresh") + @PreAuthorize("isAuthenticated()") + public ResponseEntity 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.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -18,6 +19,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.jpdfium.PdfDocument; +import stirling.software.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.payg.policy.PricingPolicy; /** @@ -42,9 +46,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier { private static final String PDF_CONTENT_TYPE = "application/pdf"; private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; - /** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */ - private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; - private final TempFileManager tempFileManager; @Override @@ -59,13 +60,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier { Objects.requireNonNull(policy, "policy"); FileFacts facts = inspect(file, materialisedPath); - long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy); - // toIntExact: fail loud on overflow rather than silently wrapping a billing number. - int units = - Math.toIntExact( - Math.max( - MIN_UNITS_PER_NONEMPTY_FILE, - Math.min(policy.getFileUnitCap(), rawUnits))); + int units = DocumentUnitCalculator.unitsForFile(facts.pages, facts.bytes, unitCalc(policy)); return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units); } @@ -93,17 +88,16 @@ public class DefaultDocumentClassifier implements DocumentClassifier { int totalPages = 0; long totalBytes = 0; - long rawUnitsSum = 0; String firstContentType = null; + List sizes = new ArrayList<>(files.size()); for (int i = 0; i < files.size(); i++) { MultipartFile file = files.get(i); Path path = materialisedPaths == null ? null : materialisedPaths.get(i); FileFacts facts = inspect(file, path); - // Sum the *raw* (unclamped) per-file units so the group cap below can actually bind. - // Per-file clamping in this loop would make the group cap a no-op. - rawUnitsSum = - saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy)); + // Collect raw page/byte facts; the group cap is applied over the raw sum in the + // calculator (per-file clamping here would make the group cap a no-op). + sizes.add(new FileSize(facts.pages, facts.bytes)); totalPages = saturatedAdd(totalPages, facts.pages); totalBytes = saturatedAdd(totalBytes, facts.bytes); if (firstContentType == null) { @@ -111,13 +105,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier { } } - long groupCap = (long) policy.getFileUnitCap() * files.size(); - // toIntExact: fail loud on overflow rather than silently wrapping. - int totalUnits = - Math.toIntExact( - Math.max( - (long) MIN_UNITS_PER_NONEMPTY_FILE, - Math.min(groupCap, rawUnitsSum))); + int totalUnits = DocumentUnitCalculator.unitsForGroup(sizes, unitCalc(policy)); return new DocumentMetrics( totalPages, @@ -126,6 +114,14 @@ public class DefaultDocumentClassifier implements DocumentClassifier { totalUnits); } + private static UnitCalcPolicy unitCalc(PricingPolicy policy) { + return new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()); + } + private FileFacts inspect(MultipartFile file, Path materialisedPath) { long bytes = file.getSize(); String contentType = @@ -140,19 +136,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier { return new FileFacts(pages, bytes, contentType); } - private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) { - long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L; - long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit()); - return Math.max(pageUnits, byteUnits); - } - - private static long ceilDiv(long numerator, long divisor) { - if (numerator <= 0) { - return 0; - } - return (numerator + divisor - 1) / divisor; - } - private static boolean isPdf(String contentType, String filename) { if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) { return true; 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 @@ import org.springframework.stereotype.Component; @Profile("saas") public class ByteHashSignatureExtractor implements LineageSignatureExtractor { - private static final String ALGORITHM = "SHA-256"; private static final String SIGNATURE_TYPE = "sha256"; - private static final int BUFFER_SIZE = 64 * 1024; @Override public Set 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 static org.mockito.Mockito.when; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -19,14 +23,19 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.accountlink.InstanceController.EntitlementResponse; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; import stirling.software.saas.payg.entitlement.EntitlementService; import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.instance.InstanceUsageIngestService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.EntitlementState; import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; /** * Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read. @@ -39,9 +48,22 @@ class InstanceControllerTest { @Mock private EntitlementService entitlementService; @Mock private TeamBillingService billingService; @Mock private AccountLinkService accountLinkService; + @Mock private PricingPolicyService pricingPolicyService; + @Mock private InstanceUsageIngestService usageIngestService; + @Mock private LinkedInstanceRepository linkedInstanceRepository; private InstanceController controller() { - return new InstanceController(entitlementService, billingService, accountLinkService); + return new InstanceController( + entitlementService, + billingService, + accountLinkService, + pricingPolicyService, + usageIngestService, + linkedInstanceRepository); + } + + private static PricingPolicy policy() { + return new PricingPolicy(1, 1_048_576L, 1, 1000); } @Test @@ -50,6 +72,7 @@ class InstanceControllerTest { when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L)); when(entitlementService.getSnapshot(42L)) .thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(42L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -62,6 +85,13 @@ class InstanceControllerTest { assertThat(body.periodCapUnits()).isEqualTo(1250L); // WARNED is still within budget for the gate's purposes → coarse OK. assertThat(body.state()).isEqualTo("OK"); + // Phase 2: the metering inputs the instance needs ride along. + assertThat(body.unitCalcPolicy()).isEqualTo(new UnitCalcPolicy(1, 1_048_576L, 1, 1000)); + assertThat(body.periodStart()).isNotNull(); + assertThat(body.periodEnd()).isNotNull(); + // The instance-facing read drops the cached snapshot first so a just-subscribed team's + // plan surfaces on the next poll instead of waiting out the cache TTL. + verify(entitlementService).invalidate(42L); } @Test @@ -70,6 +100,7 @@ class InstanceControllerTest { when(billingService.forTeam(7L)).thenReturn(freeBilling(500L)); when(entitlementService.getSnapshot(7L)) .thenReturn(snapshot(EntitlementState.FULL, 0L, null)); + when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -89,6 +120,7 @@ class InstanceControllerTest { when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L)); when(entitlementService.getSnapshot(8L)) .thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(8L)).thenReturn(policy()); EntitlementResponse body = controller().entitlement(token).getBody(); @@ -110,6 +142,59 @@ class InstanceControllerTest { verifyNoInteractions(entitlementService, billingService); } + @Test + void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() { + Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L); + LinkedInstance li = new LinkedInstance(); + li.setCreatedByUserId(7L); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(linkedInstanceRepository.findById(4L)).thenReturn(Optional.of(li)); + when(billingService.forTeam(99L)).thenReturn(freeBilling(10L)); + // The reported periodStart is validated against the authoritative snapshot period. + when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null)); + when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy()); + + InstanceController.UsageSyncRequest req = + new InstanceController.UsageSyncRequest( + 3L, + period, + new InstanceController.UsageSyncRequest.CategoryUnits(12, 4, 8)); + + ResponseEntity 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 @@ class InstanceControllerTest { start.plusMonths(1), false); } + + /** Snapshot with an explicit period — the sync tests need a deterministic period window. */ + private static EntitlementSnapshot snapshotForPeriod(LocalDateTime start, Long cap) { + return new EntitlementSnapshot( + EntitlementState.FULL, + FeatureSet.FULL, + List.of(FeatureGate.OFFSITE_PROCESSING), + 0L, + cap, + start, + start.plusMonths(1), + false); + } } 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 @@ class PaygWalletControllerTest { verifyNoInteractions(policyRepo, entitlementService); } + // ----------------------------------------------------------------------------------------- + // POST /wallet/refresh + // ----------------------------------------------------------------------------------------- + + @Test + void refreshWallet_dropsCallerTeamCache() { + User user = userWithId(30L, UUID.randomUUID()); + Team team = teamWithId(70L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(30L)) + .thenReturn(List.of(membership(team, user, TeamRole.MEMBER))); + + ResponseEntity 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 @@ class JobChargeServiceTest { .isInstanceOf(IllegalArgumentException.class); } + @Test + void chargeStandalone_floorsUnitsAtMinChargeUnits() { + // Pins the per-call minChargeUnits floor that the linked-instance sync path inherits: a + // daily delta below the floor bills the floor (max(delta, minChargeUnits)) — applied per + // sync-delta here, not per underlying op (documented divergence from the in-cloud per-op + // floor; can only under-bill vs per-op, never over). + long teamId = 100L; + PricingPolicy policy = stubPolicy(/*minCharge*/ 5, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(teamId)).thenReturn(policy); + + UUID jobId = UUID.randomUUID(); + when(jobService.open(any(JobContext.class), eq(5))).thenReturn(openJob(jobId)); + when(jobService.close(jobId)).thenReturn(openJob(jobId)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(teamId); + ext.setStripeCustomerId("cus_x"); + ext.setPaygSubscriptionId("sub_x"); + ext.setFreeUnitsRemaining(0L); + when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext)); + when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext)); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)) + .thenReturn( + Optional.of(chargedShadowRow(jobId, teamId, 5, 0, BillingCategory.API))); + + ChargeContext ctx = + new ChargeContext( + 7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API); + ArgumentCaptor 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 && ( - - {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.", - )} - + {phase === "finalizing" && } + + {phase === "activationSlow" && ( + )} - {publishableKey && error && ( - + {!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.", + )} + )} - > - {error} - - )} - {publishableKey && loading && !error && ( -
    - - -
    - )} - {publishableKey && canRender && stripe && clientSecret && ( - - - + {publishableKey && error && ( + + {error} + + )} + {publishableKey && loading && !error && ( +
    + + +
    + )} + {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" && ( - + )}