From 3643b3066095844907a360393834044544016b8a Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Fri, 26 Jun 2026 17:51:44 +0100 Subject: [PATCH 01/20] refactor(billing): extract doc-unit math to :proprietary.billing (shared, no behaviour change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2, PR #1 (slice 1/3). Move the pure doc-unit calculation out of the SaaS DefaultDocumentClassifier into a new stirling.software.proprietary.billing package (UnitCalcPolicy value object + DocumentUnitCalculator) so a linked self-hosted instance can cost operations with identical logic. :saas depends on :proprietary so the SaaS classifier delegates; the community core build (which excludes :proprietary) never ships EE billing logic. PricingPolicy (JPA) + the jpdfium/IO inspection stay in :saas. Behaviour-preserving — existing DefaultDocumentClassifier{,More}Test pass. --- .../billing/DocumentUnitCalculator.java | 76 +++++++++++++++++++ .../proprietary/billing/UnitCalcPolicy.java | 30 ++++++++ .../payg/docs/DefaultDocumentClassifier.java | 53 +++++-------- 3 files changed, 124 insertions(+), 35 deletions(-) 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 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/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java index 603f490a4f..04b9b182a2 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java @@ -5,6 +5,7 @@ import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -18,6 +19,9 @@ import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.jpdfium.PdfDocument; +import stirling.software.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.payg.policy.PricingPolicy; /** @@ -42,9 +46,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier { private static final String PDF_CONTENT_TYPE = "application/pdf"; private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; - /** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */ - private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; - private final TempFileManager tempFileManager; @Override @@ -59,13 +60,7 @@ public DocumentMetrics classify( Objects.requireNonNull(policy, "policy"); FileFacts facts = inspect(file, materialisedPath); - long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy); - // toIntExact: fail loud on overflow rather than silently wrapping a billing number. - int units = - Math.toIntExact( - Math.max( - MIN_UNITS_PER_NONEMPTY_FILE, - Math.min(policy.getFileUnitCap(), rawUnits))); + int units = DocumentUnitCalculator.unitsForFile(facts.pages, facts.bytes, unitCalc(policy)); return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units); } @@ -93,17 +88,16 @@ public DocumentMetrics classify( int totalPages = 0; long totalBytes = 0; - long rawUnitsSum = 0; String firstContentType = null; + List sizes = new ArrayList<>(files.size()); for (int i = 0; i < files.size(); i++) { MultipartFile file = files.get(i); Path path = materialisedPaths == null ? null : materialisedPaths.get(i); FileFacts facts = inspect(file, path); - // Sum the *raw* (unclamped) per-file units so the group cap below can actually bind. - // Per-file clamping in this loop would make the group cap a no-op. - rawUnitsSum = - saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy)); + // Collect raw page/byte facts; the group cap is applied over the raw sum in the + // calculator (per-file clamping here would make the group cap a no-op). + sizes.add(new FileSize(facts.pages, facts.bytes)); totalPages = saturatedAdd(totalPages, facts.pages); totalBytes = saturatedAdd(totalBytes, facts.bytes); if (firstContentType == null) { @@ -111,13 +105,7 @@ public DocumentMetrics classify( } } - long groupCap = (long) policy.getFileUnitCap() * files.size(); - // toIntExact: fail loud on overflow rather than silently wrapping. - int totalUnits = - Math.toIntExact( - Math.max( - (long) MIN_UNITS_PER_NONEMPTY_FILE, - Math.min(groupCap, rawUnitsSum))); + int totalUnits = DocumentUnitCalculator.unitsForGroup(sizes, unitCalc(policy)); return new DocumentMetrics( totalPages, @@ -126,6 +114,14 @@ public DocumentMetrics classify( totalUnits); } + private static UnitCalcPolicy unitCalc(PricingPolicy policy) { + return new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()); + } + private FileFacts inspect(MultipartFile file, Path materialisedPath) { long bytes = file.getSize(); String contentType = @@ -140,19 +136,6 @@ private FileFacts inspect(MultipartFile file, Path materialisedPath) { return new FileFacts(pages, bytes, contentType); } - private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) { - long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L; - long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit()); - return Math.max(pageUnits, byteUnits); - } - - private static long ceilDiv(long numerator, long divisor) { - if (numerator <= 0) { - return 0; - } - return (numerator + divisor - 1) / divisor; - } - private static boolean isPdf(String contentType, String filename) { if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) { return true; From 9097ccee9127abff80c6c12ec506ffa2ffc5bb05 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 29 Jun 2026 13:01:50 +0100 Subject: [PATCH 02/20] feat(billing): 3-way category classifier in :proprietary.billing; gate covers API-key calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (foundation). Add proprietary.billing.BillingCategory + BillingCategoryClassifier (pure AUTOMATION → AI → API → BYPASSED precedence, shared by the instance gate + upcoming meter). BillableOperationClassifier now returns a BillingCategory via categorize(request, apiKey) instead of a coarse boolean, and InstanceEntitlementInterceptor resolves the API-key principal and blocks any non-BYPASSED category — closing a gap where plain API-key tool calls were neither gated nor counted (the feature meters API/Automation/AI). Deliberately did NOT move the SaaS payg.model.BillingCategory enum into :proprietary: that would churn ~19 in-cloud hot-path files for what is JSON-string metadata on the sync wire. The instance reports category names; SaaS maps them. Flag-gated, @Profile("!saas"). +tests. --- .../BillableOperationClassifier.java | 38 +++++++--- .../InstanceEntitlementInterceptor.java | 12 ++- .../proprietary/billing/BillingCategory.java | 22 ++++++ .../billing/BillingCategoryClassifier.java | 29 ++++++++ .../BillableOperationClassifierTest.java | 73 +++++++++++++------ .../BillingCategoryClassifierTest.java | 30 ++++++++ 6 files changed, 171 insertions(+), 33 deletions(-) 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/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java index 136e4c3214..476fbd111a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java @@ -3,14 +3,26 @@ import jakarta.servlet.http.HttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.BillingCategoryClassifier; /** - * Classifies a request as billable (AI / automation) or free (a manual tool). + * Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only + * HTTP-level signals (no dependency on the saas module): * - *

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

+ * + *

Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared + * {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the + * saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the + * caller (resolved from the security context), so this class stays free of any security-type + * dependency. */ public final class BillableOperationClassifier { @@ -18,16 +30,22 @@ public final class BillableOperationClassifier { private BillableOperationClassifier() {} - public static boolean isBillable(HttpServletRequest request) { - if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) { - return true; - } + /** + * @param apiKey whether the request authenticated via an API key (an {@code + * ApiKeyAuthenticationToken} principal), resolved by the caller from the security context. + */ + public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) { + boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null; + return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey); + } + + private static boolean isAiSurface(HttpServletRequest request) { String uri = request.getRequestURI(); if (uri == null) { return false; } // Prefix-match the AI surface (not a loose substring contains), stripping a deployment - // context path so //api/v1/ai/** still classifies as billable. + // context path so //api/v1/ai/** still classifies as AI. String ctx = request.getContextPath(); String path = ctx != null && !ctx.isEmpty() && uri.startsWith(ctx) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 8597813a85..6616b6b15d 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 @@ -3,6 +3,7 @@ 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.servlet.HandlerInterceptor; @@ -11,6 +12,9 @@ import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.BillingCategory; +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. @@ -41,7 +45,13 @@ public boolean preHandle( throws Exception { GateDecision decision; try { - decision = gate.evaluate(BillableOperationClassifier.isBillable(request)); + // API-key tool calls are billable too (category API), so resolve the auth principal and + // gate on "not a manual UI tool". Same categorisation the meter will use. + boolean apiKey = + SecurityContextHolder.getContext().getAuthentication() + instanceof ApiKeyAuthenticationToken; + BillingCategory category = BillableOperationClassifier.categorize(request, apiKey); + 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. 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/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java index 275026794a..0b43069ee9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java @@ -1,49 +1,78 @@ package stirling.software.proprietary.accountlink; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; class BillableOperationClassifierTest { + private static MockHttpServletRequest req(String uri) { + return new MockHttpServletRequest("POST", uri); + } + @Test - void aiPathIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo"); - assertTrue(BillableOperationClassifier.isBillable(req)); + void aiPathIsAi() { + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false)); } @Test - void automationHeaderIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + void automationHeaderIsAutomation() { + MockHttpServletRequest req = req("/api/v1/general/merge"); req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals( + BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false)); + } + + @Test + void apiKeyToolCallIsApi() { + assertEquals( + BillingCategory.API, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true)); + } + + @Test + void plainManualToolIsBypassed() { + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false)); + } + + @Test + void automationDominatesAiAndApiKey() { + // An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION. + MockHttpServletRequest req = req("/api/v1/ai/tools/foo"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true"); + assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true)); } @Test - void plainManualToolIsFree() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void aiDominatesApiKey() { + // A direct API-key call to an AI tool bills as AI, not API. + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true)); } @Test - void aiSegmentNotAtPathStartIsFree() { - // Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a - // proxy prefix) must NOT classify a manual tool as billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void aiSegmentNotAtPathStartIsBypassed() { + // Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix) + // must NOT classify a manual tool as AI. + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false)); } @Test - void aiPathUnderContextPathIsBillable() { - // A real context-path deployment still classifies: //api/v1/ai/** is billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo"); + void aiPathUnderContextPathIsAi() { + // A real context-path deployment still classifies: //api/v1/ai/** is AI. + MockHttpServletRequest req = req("/stirling/api/v1/ai/tools/foo"); req.setContextPath("/stirling"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals(BillingCategory.AI, BillableOperationClassifier.categorize(req, false)); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/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)); + } +} From 8be64ab70bfeb62c5a37e7d4fc302a821cfb5b5b Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 29 Jun 2026 14:25:58 +0100 Subject: [PATCH 03/20] feat(account-link): entitlement sync carries unit-calc policy + billing period Phase 2, step 1a. The SaaS GET /api/v1/instance/entitlement response now includes the team's UnitCalcPolicy (doc-unit knobs) and period start/end, so a linked instance can compute units locally (via the shared DocumentUnitCalculator) and key its per-period cumulative counters on the [periodStart, periodEnd) boundary. InstanceEntitlement gains the three metering fields plus a 5-arg gate-only constructor, so the revoked sentinel + gate tests are unchanged. AccountLinkClient parses them tolerantly: a missing or invalid policy/period degrades to null rather than failing the whole entitlement parse. Flag-gated both sides. +tests (SaaS emits, asserted). --- .../accountlink/AccountLinkClient.java | 43 ++++++++++++++++++- .../accountlink/InstanceEntitlement.java | 42 ++++++++++++++++-- .../saas/accountlink/InstanceController.java | 28 ++++++++++-- .../accountlink/InstanceControllerTest.java | 18 +++++++- 4 files changed, 122 insertions(+), 9 deletions(-) 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..e6ef80c4a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -6,6 +6,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.time.LocalDateTime; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -14,6 +15,8 @@ import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.UnitCalcPolicy; + import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -226,7 +229,45 @@ private InstanceEntitlement parseEntitlement(String body) throws IOException { Long periodCap = root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null; EntitlementState state = mapState(root.path("state").asText(null)); - return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state); + return new InstanceEntitlement( + subscribed, + freeRemaining, + periodSpend, + periodCap, + state, + parseUnitCalcPolicy(root), + parseDateTime(root, "periodStart"), + parseDateTime(root, "periodEnd")); + } + + /** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */ + private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) { + if (!root.hasNonNull("unitCalcPolicy")) { + return null; + } + JsonNode node = root.get("unitCalcPolicy"); + try { + return new UnitCalcPolicy( + node.path("docPagesPerUnit").asInt(), + node.path("docBytesPerUnit").asLong(), + node.path("minChargeUnits").asInt(), + node.path("fileUnitCap").asInt()); + } catch (RuntimeException e) { + // Malformed policy → degrade to "none" rather than fail the whole entitlement parse. + return null; + } + } + + /** ISO date-time field → LocalDateTime; null if absent or unparseable. */ + private static LocalDateTime parseDateTime(JsonNode root, String field) { + if (!root.hasNonNull(field)) { + return null; + } + try { + return LocalDateTime.parse(root.get(field).asText(null)); + } catch (RuntimeException e) { + return null; + } } /** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/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/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java index 7392eb928a..d6a9d915d3 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,7 @@ package stirling.software.saas.accountlink; +import java.time.LocalDateTime; + import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -16,11 +18,14 @@ 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.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 +51,17 @@ public class InstanceController { private final EntitlementService entitlementService; private final TeamBillingService billingService; private final AccountLinkService accountLinkService; + private final PricingPolicyService pricingPolicyService; public InstanceController( EntitlementService entitlementService, TeamBillingService billingService, - AccountLinkService accountLinkService) { + AccountLinkService accountLinkService, + PricingPolicyService pricingPolicyService) { this.entitlementService = entitlementService; this.billingService = billingService; this.accountLinkService = accountLinkService; + this.pricingPolicyService = pricingPolicyService; } public record WhoAmIResponse(Long instanceId, Long teamId) {} @@ -68,7 +76,13 @@ public record EntitlementResponse( long freeRemainingUnits, long periodSpendUnits, Long periodCapUnits, - String state) {} + String state, + // Metering inputs the instance needs to cost + bucket its own usage (Phase 2). The + // instance computes units locally with this policy and resets its per-period cumulative + // counters on the [periodStart, periodEnd) boundary. + UnitCalcPolicy unitCalcPolicy, + LocalDateTime periodStart, + LocalDateTime periodEnd) {} @GetMapping("/whoami") @PreAuthorize("hasRole('LINKED_INSTANCE')") @@ -109,6 +123,7 @@ public ResponseEntity entitlement(Authentication auth) { // TeamBillingService, period spend/cap + state from the entitlement snapshot. TeamBillingContext billing = billingService.forTeam(teamId); EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); + PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId); return ResponseEntity.ok( new EntitlementResponse( @@ -116,7 +131,14 @@ public ResponseEntity entitlement(Authentication auth) { billing.freeRemainingUnits(), snap.periodSpendUnits(), snap.periodCapUnits(), - coarseState(snap.state()))); + coarseState(snap.state()), + new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()), + snap.periodStart(), + snap.periodEnd())); } /** 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..3172120df9 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 @@ -19,6 +19,7 @@ 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; @@ -27,6 +28,8 @@ 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 +42,15 @@ class InstanceControllerTest { @Mock private EntitlementService entitlementService; @Mock private TeamBillingService billingService; @Mock private AccountLinkService accountLinkService; + @Mock private PricingPolicyService pricingPolicyService; private InstanceController controller() { - return new InstanceController(entitlementService, billingService, accountLinkService); + return new InstanceController( + entitlementService, billingService, accountLinkService, pricingPolicyService); + } + + private static PricingPolicy policy() { + return new PricingPolicy(1, 1_048_576L, 1, 1000); } @Test @@ -50,6 +59,7 @@ void entitlement_resolvesTeamFromTokenAndMapsSnapshot() { when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L)); when(entitlementService.getSnapshot(42L)) .thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(42L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -62,6 +72,10 @@ void entitlement_resolvesTeamFromTokenAndMapsSnapshot() { assertThat(body.periodCapUnits()).isEqualTo(1250L); // WARNED is still within budget for the gate's purposes → coarse OK. assertThat(body.state()).isEqualTo("OK"); + // Phase 2: the metering inputs the instance needs ride along. + assertThat(body.unitCalcPolicy()).isEqualTo(new UnitCalcPolicy(1, 1_048_576L, 1, 1000)); + assertThat(body.periodStart()).isNotNull(); + assertThat(body.periodEnd()).isNotNull(); } @Test @@ -70,6 +84,7 @@ void entitlement_uncapped_returnsNullCapUnits() { when(billingService.forTeam(7L)).thenReturn(freeBilling(500L)); when(entitlementService.getSnapshot(7L)) .thenReturn(snapshot(EntitlementState.FULL, 0L, null)); + when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -89,6 +104,7 @@ void entitlement_degradedMapsToOverLimit() { when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L)); when(entitlementService.getSnapshot(8L)) .thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(8L)).thenReturn(policy()); EntitlementResponse body = controller().entitlement(token).getBody(); From 797781c89dcedaadb77a655371f376cac42e909c Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 29 Jun 2026 14:43:17 +0100 Subject: [PATCH 04/20] feat(account-link): metering flag + durable cumulative usage counter store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2, step 1b. Add the dedicated billing switch `stirling.billing.account-link.metering.enabled` — separate from the link master flag so linking can be enabled (e.g. to test it) without ever turning on real metering / billing — plus `metering.sync-interval-hours` (24) and `metering.grace-days` (3). Add the per-(period, category) UsageCounter entity (auto-created by Hibernate on self-hosted; inert empty table until metering writes it), its repository with an atomic SQL increment, and UsageMeterService.accrue(): race-safe increment-or-insert, gated on metering.enabled. The cumulative model is idempotent + tamper-evident for the daily sync. +tests. Wiring this into the gate interceptor's success path (compute units, accrue) is the next slice. --- .../accountlink/AccountLinkProperties.java | 27 +++++++ .../proprietary/accountlink/UsageCounter.java | 70 ++++++++++++++++++ .../accountlink/UsageCounterRepository.java | 34 +++++++++ .../accountlink/UsageMeterService.java | 69 +++++++++++++++++ .../accountlink/UsageMeterServiceTest.java | 74 +++++++++++++++++++ 5 files changed, 274 insertions(+) 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/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java 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..cafdd226ff 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 @@ -36,4 +36,31 @@ 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; + } } 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..61d2f87deb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -0,0 +1,70 @@ +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; + +/** + * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A" + * metering. Each successful billable operation increments the matching row; the daily sync reports + * these 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 ({@code + * ddl-auto=update} on self-hosted); only the flag-gated {@link UsageMeterService} writes it, so + * when metering is off the table is simply an empty additive table. + */ +@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; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public UsageCounter( + LocalDateTime periodStart, + String category, + long cumulativeUnits, + LocalDateTime updatedAt) { + this.periodStart = periodStart; + this.category = category; + this.cumulativeUnits = cumulativeUnits; + this.updatedAt = updatedAt; + } +} 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..3e57042442 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -0,0 +1,34 @@ +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); +} 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..3271271f57 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java @@ -0,0 +1,69 @@ +package stirling.software.proprietary.accountlink; + +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} on the + * instance. The daily sync later reports the cumulative totals to SaaS. + * + *

Gated behind the dedicated {@code stirling.billing.account-link.metering.enabled} switch (on + * top of {@code @Profile("!saas")}), so the bean is absent — and nothing accrues — unless billing + * is explicitly turned on. {@link #accrue} is best-effort and self-contained: callers (the gate + * interceptor's success path) 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; + + public UsageMeterService(UsageCounterRepository repo) { + this.repo = repo; + } + + /** + * Adds {@code units} to the {@code (periodStart, category)} counter, creating the row on first + * use. Atomic increment-or-insert: an UPDATE first, falling back to an INSERT, and on a lost + * insert race a second increment. No-ops for non-billable categories, non-positive units, or a + * missing period (e.g. entitlement not yet synced). + */ + public void accrue(LocalDateTime periodStart, BillingCategory category, long units) { + if (periodStart == null + || category == null + || category == BillingCategory.BYPASSED + || units <= 0) { + return; + } + String cat = category.name(); + LocalDateTime now = LocalDateTime.now(); + try { + if (repo.increment(periodStart, cat, units, now) > 0) { + return; + } + try { + repo.saveAndFlush(new UsageCounter(periodStart, cat, units, now)); + } catch (DataIntegrityViolationException raceLostInsert) { + // A concurrent request inserted the row first — increment the now-existing row. + repo.increment(periodStart, cat, units, now); + } + } catch (RuntimeException e) { + // Metering must never break the request it rode in on; lost accrual self-heals on the + // next operation's increment, and the daily sync reports the cumulative total either + // way. + log.debug("Usage accrual failed for {}/{}: {}", periodStart, cat, e.getMessage()); + } + } +} 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..a2fa50cd6c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java @@ -0,0 +1,74 @@ +package stirling.software.proprietary.accountlink; + +import static org.mockito.ArgumentMatchers.any; +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 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; + + private UsageMeterService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new UsageMeterService(repo); + } + + @Test + void incrementsExistingCounter() { + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5); + + 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); + + 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); + + verify(repo, times(2)).increment(eq(period), eq("AUTOMATION"), eq(2L), any()); + } + + @Test + void skipsBypassedNonPositiveAndNullPeriod() { + service.accrue(period, BillingCategory.BYPASSED, 5); + service.accrue(period, BillingCategory.AI, 0); + service.accrue(null, BillingCategory.AI, 5); + + verifyNoInteractions(repo); + } +} From 7eea0b1c595e99981af064f5cbe6c46e947f72a4 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 29 Jun 2026 14:46:11 +0100 Subject: [PATCH 05/20] feat(account-link): meter successful billable ops into the cumulative counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2, step 1c. On afterCompletion (success only), InstanceEntitlementInterceptor computes doc-units for the request via the shared DocumentUnitCalculator + the synced UnitCalcPolicy and accrues them to the per-period UsageCounter for the request's BillingCategory. The category is classified once in preHandle (for the gate) and reused. Metering is gated behind metering.enabled via ObjectProvider — absent ⇒ no accrual, gate still works — and skipped until the entitlement carries a policy + period. KNOWN GAP (pre-flag-on): units are the bytes axis only; PDF page-counting (materialising uploads + jpdfium) is a follow-up, so page-heavy small PDFs currently under-count vs SaaS. +tests. --- .../InstanceEntitlementInterceptor.java | 92 +++++++++++++++++-- .../InstanceEntitlementInterceptorTest.java | 66 ++++++++++++- 2 files changed, 149 insertions(+), 9 deletions(-) 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 6616b6b15d..c329d17d00 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,11 +1,18 @@ package stirling.software.proprietary.accountlink; +import java.util.ArrayList; +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; @@ -13,19 +20,24 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.billing.BillingCategory; +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} classifies the request + * and blocks billable (API / AI / automation) work when the instance is unlinked or over its limit; + * manual tools pass straight through. {@code afterCompletion} meters a successful billable + * op into the per-period cumulative counter (the daily sync later reports the totals). * *

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. + * activate" prompt. 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. + *

Gated + {@code @Profile("!saas")}; metering is additionally gated behind {@code + * …account-link.metering.enabled} via an {@link ObjectProvider} — when that switch is off the + * {@link UsageMeterService} bean is absent and nothing accrues, while the gate still works. */ @Slf4j @Component @@ -33,10 +45,20 @@ @ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class InstanceEntitlementInterceptor implements HandlerInterceptor { + private static final String ATTR_CATEGORY = + InstanceEntitlementInterceptor.class.getName() + ".category"; + private final InstanceEntitlementGate gate; + private final EntitlementCache entitlementCache; + private final ObjectProvider meterProvider; - public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) { + public InstanceEntitlementInterceptor( + InstanceEntitlementGate gate, + EntitlementCache entitlementCache, + ObjectProvider meterProvider) { this.gate = gate; + this.entitlementCache = entitlementCache; + this.meterProvider = meterProvider; } @Override @@ -46,11 +68,12 @@ public boolean preHandle( GateDecision decision; try { // API-key tool calls are billable too (category API), so resolve the auth principal and - // gate on "not a manual UI tool". Same categorisation the meter will use. + // gate on "not a manual UI tool". Stash the category for afterCompletion's 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 @@ -72,4 +95,57 @@ public boolean preHandle( + "\"}"); return false; } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + // Meter successful billable ops only. + if (ex != null || response.getStatus() >= 400) { + return; + } + UsageMeterService meter = meterProvider.getIfAvailable(); + if (meter == null) { + return; // metering switch off + } + if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category) + || category == BillingCategory.BYPASSED) { + return; + } + try { + InstanceEntitlement ent = entitlementCache.current().orElse(null); + if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) { + // Not yet synced (no policy/period) — can't compute units; skip until next sync. + return; + } + meter.accrue(ent.periodStart(), category, computeUnits(request, ent.unitCalcPolicy())); + } catch (RuntimeException e) { + // Metering must never affect the response that already completed. + log.debug("Usage metering failed for {}", request.getRequestURI(), e); + } + } + + /** + * Doc-units for this request via the shared calculator. Counts uploaded file bytes; page counts + * are not read here yet (a follow-up will materialise PDFs for the page axis), so this is the + * bytes-axis lower bound — a non-file billable op costs 1 unit. + */ + private static long computeUnits(HttpServletRequest request, UnitCalcPolicy policy) { + MultipartHttpServletRequest mreq = + WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); + if (mreq == null) { + return DocumentUnitCalculator.unitsForFile(0, 0, policy); + } + List sizes = new ArrayList<>(); + for (List files : mreq.getMultiFileMap().values()) { + for (MultipartFile f : files) { + sizes.add(new FileSize(0, f.getSize())); + } + } + return sizes.isEmpty() + ? DocumentUnitCalculator.unitsForFile(0, 0, policy) + : DocumentUnitCalculator.unitsForGroup(sizes, policy); + } } 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..9c8d333ee1 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 @@ -4,23 +4,40 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +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.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 org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +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; + + private InstanceEntitlementInterceptor interceptor() { + return new InstanceEntitlementInterceptor(gate, entitlementCache, meterProvider); + } 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 +75,51 @@ void failsOpenWhenGateThrows() throws Exception { assertTrue(preHandle(response)); assertEquals(200, response.getStatus()); } + + @Test + void metersSuccessfulBillableOp() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()) + .thenReturn( + Optional.of( + new InstanceEntitlement( + true, + 0, + 0, + 100L, + EntitlementState.OK, + policy, + period, + period.plusMonths(1)))); + + 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. + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L)); + } + + @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); + } } From ddf170857354ce42826ababd8c1e19401e1d6713 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 29 Jun 2026 15:42:00 +0100 Subject: [PATCH 06/20] =?UTF-8?q?feat(billing):=20SaaS=20daily-sync=20inge?= =?UTF-8?q?st=20=E2=80=94=20bill=20linked-instance=20usage=20delta=20via?= =?UTF-8?q?=20chargeStandalone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instance reports monotonic cumulative units per BillingCategory each day; SaaS bills only the delta since the last sync. Cumulative+seq make it idempotent (resend → delta 0) and tamper-evident (a backwards total is refused, not credited). Reuses JobChargeService.chargeStandalone for the money path (free-grant split, wallet_ledger DEBIT, Stripe meter, idempotency) — no separate billing logic. - V25 payg_instance_usage: last-seen cumulative + sync_seq per (team, period, category) - PaygInstanceUsage entity + repository - InstanceUsageIngestService: delta/replay/regression/null-actor handling - InstanceController POST /sync: resolves actor from linked_instance.created_by_user_id, ingests, returns fresh entitlement (one round-trip reports + refreshes the gate) - JobSource.LINKED_INSTANCE + ReferenceType.INSTANCE_SYNC --- .../saas/accountlink/InstanceController.java | 90 +++++++++--- .../instance/InstanceUsageIngestService.java | 132 ++++++++++++++++++ .../saas/payg/instance/PaygInstanceUsage.java | 74 ++++++++++ .../software/saas/payg/model/JobSource.java | 9 +- .../saas/payg/model/ReferenceType.java | 4 +- .../PaygInstanceUsageRepository.java | 15 ++ .../saas/V25__payg_instance_usage.sql | 30 ++++ .../accountlink/InstanceControllerTest.java | 46 +++++- .../InstanceUsageIngestServiceTest.java | 120 ++++++++++++++++ 9 files changed, 500 insertions(+), 20 deletions(-) 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/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java 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 d6a9d915d3..f1d53b6dc8 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,6 +1,7 @@ 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; @@ -11,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -23,6 +25,8 @@ 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; @@ -52,16 +56,22 @@ public class InstanceController { 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, - PricingPolicyService pricingPolicyService) { + 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) {} @@ -117,28 +127,74 @@ public ResponseEntity entitlement(Authentication auth) { if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + 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(); + // 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())); + 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); PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId); - - return ResponseEntity.ok( - 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())); + 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/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java new file mode 100644 index 0000000000..d53f553f98 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -0,0 +1,132 @@ +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 self-hosted instance's daily usage sync (combined-billing "Mode A"). + * + *

The instance reports a monotonic cumulative unit total per {@link BillingCategory} for + * the current billing period. We bill only the delta since the last sync, which makes the + * model idempotent (a resend reports the same total → delta 0 → no charge) and tamper-evident (a + * total that goes backwards is refused, not credited). The charge itself reuses {@link + * JobChargeService#chargeStandalone} — the same free-grant split, {@code wallet_ledger} DEBIT, + * Stripe meter, and idempotency the in-cloud charge path uses — so there is no separate billing + * logic for this flow. + * + *

Gated behind {@code stirling.billing.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) { + PaygInstanceUsage row = + usageRepository + .findByTeamIdAndPeriodStartAndCategory(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/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/model/ReferenceType.java b/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java index d966b3d22e..9ea42d9965 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java @@ -5,5 +5,7 @@ public enum ReferenceType { JOB, INVOICE, STRIPE_EVENT, - ADMIN + ADMIN, + /** A linked self-hosted instance's daily usage sync (combined-billing "Mode A"). */ + INSTANCE_SYNC } 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..9836c616e0 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java @@ -0,0 +1,15 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +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); +} 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..dfa5d255a5 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql @@ -0,0 +1,30 @@ +-- 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/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java index 3172120df9..a9bbbeb936 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.saas.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -8,9 +9,12 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -25,6 +29,8 @@ 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; @@ -43,10 +49,17 @@ class InstanceControllerTest { @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, pricingPolicyService); + entitlementService, + billingService, + accountLinkService, + pricingPolicyService, + usageIngestService, + linkedInstanceRepository); } private static PricingPolicy policy() { @@ -126,6 +139,37 @@ void entitlement_nonInstancePrincipalIsRejected() { verifyNoInteractions(entitlementService, billingService); } + @Test + void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() { + Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L); + LinkedInstance li = new LinkedInstance(); + li.setCreatedByUserId(7L); + when(linkedInstanceRepository.findById(4L)).thenReturn(Optional.of(li)); + when(billingService.forTeam(99L)).thenReturn(freeBilling(10L)); + when(entitlementService.getSnapshot(99L)) + .thenReturn(snapshot(EntitlementState.FULL, 5L, null)); + when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy()); + + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + 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); + } + @Test void revokeSelf_callsServiceWithTokenIdentityAndReturns204() { Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L); 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..f2e9133523 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java @@ -0,0 +1,120 @@ +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.findByTeamIdAndPeriodStartAndCategory(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.findByTeamIdAndPeriodStartAndCategory(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L)); + + 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.findByTeamIdAndPeriodStartAndCategory(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.findByTeamIdAndPeriodStartAndCategory(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.findByTeamIdAndPeriodStartAndCategory(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 nullActorSkipsEntirely() { + service.ingest(1L, null, 1L, period, Map.of(BillingCategory.AI, 10L)); + + verifyNoInteractions(repo, chargeService); + } +} From 1a3cb71031ebbea29bd9363ee9ceb70924c8a257 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 29 Jun 2026 15:52:15 +0100 Subject: [PATCH 07/20] =?UTF-8?q?feat(billing):=20instance=20daily-sync=20?= =?UTF-8?q?sender=20=E2=80=94=20report=20cumulative=20usage=20to=20SaaS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counterpart to the SaaS ingest: a flag-gated @Scheduled sender on the self-hosted side reports each period's cumulative per-category units to POST /api/v1/instance/sync and adopts the refreshed entitlement from the reply. Resilience by design: - sync seq reserved + persisted BEFORE the report (strictly monotonic across restarts/failures; SaaS dedups replays on it) - transport failure leaves lastSyncedUnits markers untouched → usage rolls into the next sync; the burned seq is harmless - reports ALL periods with unsynced usage, not just the current one, so end-of-period usage isn't stranded on period rollover - revoked credential aborts reporting; the entitlement cache blocks on its own refresh - UsageCounter.lastSyncedUnits (+ markSynced / findPeriodsWithUnsyncedUsage) - AccountLinkSyncState singleton (seq + lastSuccessAt) + repository - AccountLinkClient.reportUsage (mirrors fetchEntitlement outcomes) - EntitlementCache.accept (seed cache from the sync reply, no redundant fetch) - UsageSyncService scheduler (interval from metering.sync-interval-hours) --- .../accountlink/AccountLinkClient.java | 69 ++++++- .../accountlink/AccountLinkSyncState.java | 42 +++++ .../AccountLinkSyncStateRepository.java | 6 + .../accountlink/EntitlementCache.java | 10 + .../proprietary/accountlink/UsageCounter.java | 7 + .../accountlink/UsageCounterRepository.java | 23 +++ .../accountlink/UsageSyncService.java | 173 ++++++++++++++++++ .../accountlink/AccountLinkClientTest.java | 70 +++++++ .../accountlink/UsageSyncServiceTest.java | 141 ++++++++++++++ 9 files changed, 540 insertions(+), 1 deletion(-) 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/UsageSyncService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.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 e6ef80c4a4..36267a9ec3 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 @@ -24,13 +24,17 @@ * 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 @@ -221,6 +225,69 @@ public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret } } + /** + * Reports the current 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 usage and refreshes the gate. SaaS bills the delta against its own last-seen + * cumulative, so reporting the same totals twice charges nothing (idempotent). + * + *

Same three outcomes as {@link #fetchEntitlement}: 2xx → parsed snapshot; 401/403 → {@link + * RevokedException}; transport / other non-2xx / malformed body → {@code null} (caller must not + * advance its last-synced markers, so the usage retries on the next sync). + */ + public InstanceEntitlement reportUsage( + String deviceId, + String deviceSecret, + long syncSeq, + LocalDateTime periodStart, + long apiUnits, + long aiUnits, + long automationUnits) { + HttpResponse response; + try { + String body = + "{\"syncSeq\":" + + syncSeq + + ",\"periodStart\":\"" + + periodStart + + "\",\"cumulativeUnits\":{\"api\":" + + apiUnits + + ",\"ai\":" + + aiUnits + + ",\"automation\":" + + automationUnits + + "}}"; + 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); 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..a541d260ca --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java @@ -0,0 +1,42 @@ +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; + + @Column(name = "last_sync_seq", nullable = false) + 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/EntitlementCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java index 63818c1f98..487e1ba441 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 @@ -121,4 +121,14 @@ void refresh() { public void invalidate() { snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH); } + + /** + * Seeds the cache with an entitlement obtained out-of-band — the daily usage sync gets a fresh + * snapshot back in its reply, so adopting it here saves 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/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java index 61d2f87deb..b8fe1eeedd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -54,6 +54,13 @@ public class UsageCounter { @Column(name = "cumulative_units", nullable = false) private long cumulativeUnits; + /** + * Value of {@link #cumulativeUnits} as of the last sync SaaS accepted. {@code cumulativeUnits − + * lastSyncedUnits} is the as-yet-unreported usage the portal shows on top of SaaS-synced spend. + */ + @Column(name = "last_synced_units", nullable = false) + private long lastSyncedUnits; + @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; 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 index 3e57042442..2140775abc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -31,4 +31,27 @@ int increment( /** 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/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java new file mode 100644 index 0000000000..bce10329ca --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -0,0 +1,173 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +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.Scheduled; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * 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. + * + *

The model is deliberately resilient: the sync seq is reserved (persisted) before the + * report so it never regresses across restarts/failures; a transport failure leaves the per-counter + * {@code lastSyncedUnits} markers untouched so the usage simply rolls into the next sync; and + * reporting the same cumulative twice bills nothing (SaaS dedups on the delta + seq). All periods + * with unsynced usage are reported, not just the current one, so usage isn't stranded when the + * billing period rolls over between syncs. + * + *

Gated behind the dedicated {@code stirling.billing.account-link.metering.enabled} switch (plus + * {@code @Profile("!saas")}): off → bean absent → nothing syncs. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty( + name = "stirling.billing.account-link.metering.enabled", + havingValue = "true") +public class UsageSyncService { + + private final UsageCounterRepository counters; + private final AccountLinkSyncStateRepository syncState; + private final DeviceCredentialStore credentialStore; + private final AccountLinkClient client; + private final EntitlementCache entitlementCache; + + public UsageSyncService( + UsageCounterRepository counters, + AccountLinkSyncStateRepository syncState, + DeviceCredentialStore credentialStore, + AccountLinkClient client, + EntitlementCache entitlementCache) { + this.counters = counters; + this.syncState = syncState; + this.credentialStore = credentialStore; + this.client = client; + this.entitlementCache = entitlementCache; + } + + // First run waits out startup (entitlement fetch + any restart churn); then every interval. + @Scheduled( + initialDelay = 300_000L, + fixedDelayString = + "#{T(java.time.Duration).ofHours(${stirling.billing.account-link.metering.sync-interval-hours:24}).toMillis()}") + 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()) { + return; // nothing accrued since the last successful sync + } + 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) { + long api = 0; + long ai = 0; + long automation = 0; + for (UsageCounter c : counters.findByPeriodStart(period)) { + switch (c.getCategory()) { + case "API" -> api = c.getCumulativeUnits(); + case "AI" -> ai = c.getCumulativeUnits(); + case "AUTOMATION" -> automation = c.getCumulativeUnits(); + default -> { + // BYPASSED never accrues; ignore any unexpected category. + } + } + } + long seq = reserveNextSeq(); + InstanceEntitlement fresh = + client.reportUsage( + cred.getDeviceId(), + cred.getDeviceSecret(), + seq, + period, + api, + ai, + automation); + if (fresh == null) { + // Transport/server failure: leave the synced markers; the burned seq is harmless (seqs + // need only be monotonic) and SaaS bills the delta on the next successful sync. + return null; + } + recordSuccess(period, api, ai, automation); + return fresh; + } + + /** Reserves and persists the next strictly-increasing sequence before the report goes out. */ + private long reserveNextSeq() { + AccountLinkSyncState state = loadState(); + long next = state.getLastSyncSeq() + 1; + state.setLastSyncSeq(next); + syncState.save(state); + return next; + } + + /** Advances the per-counter synced markers to the reported totals + stamps the success time. */ + private void recordSuccess(LocalDateTime period, long api, long ai, long automation) { + if (api > 0) { + counters.markSynced(period, "API", api); + } + if (ai > 0) { + counters.markSynced(period, "AI", ai); + } + if (automation > 0) { + counters.markSynced(period, "AUTOMATION", automation); + } + AccountLinkSyncState state = loadState(); + 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/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java index 7d954a4398..969111e9f5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -12,6 +12,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -189,4 +190,73 @@ void revokeSelfReturnsFalseWhenUnreachable() throws Exception { .thenThrow(new ConnectException("refused")); assertEquals(false, client.revokeSelf("dev-1", "sec-1")); } + + @Test + @SuppressWarnings("unchecked") + void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception { + HttpResponse resp = + response( + 200, + "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + InstanceEntitlement e = + client.reportUsage( + "dev-1", "sec-1", 7L, LocalDateTime.of(2026, 6, 1, 0, 0), 12, 4, 8); + + assertNotNull(e); + assertEquals(42, e.periodSpendUnits()); + assertEquals(EntitlementState.OK, e.state()); + + HttpRequest sent = captor.getValue(); + assertEquals("https://saas.example.com/api/v1/instance/sync", sent.uri().toString()); + assertEquals("POST", sent.method()); + assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null)); + assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageThrowsRevokedOnDeny() throws Exception { + for (int status : new int[] {401, 403}) { + HttpResponse resp = response(status, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + AccountLinkClient.RevokedException ex = + assertThrows( + AccountLinkClient.RevokedException.class, + () -> + client.reportUsage( + "dev-1", + "sec-1", + 1L, + LocalDateTime.of(2026, 6, 1, 0, 0), + 1, + 0, + 0)); + assertEquals(status, ex.status()); + } + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageReturnsNullWhenUnreachable() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenThrow(new ConnectException("refused")); + // Null = don't advance synced markers; the usage retries on the next sync. + assertNull( + client.reportUsage( + "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0)); + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageReturnsNullOnServerError() throws Exception { + HttpResponse resp = response(503, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + assertNull( + client.reportUsage( + "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0)); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java new file mode 100644 index 0000000000..47bab5ed02 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java @@ -0,0 +1,141 @@ +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.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 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); + } + + 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 nothingPendingDoesNotReport() { + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of()); + + service.syncNow(); + + verifyNoInteractions(client); + verify(syncState, never()).save(any()); + verify(entitlementCache, never()).accept(any()); + } + + @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()); + } +} From 912377f5e8560919731a9e99b8ab3cdfd79bee86 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Mon, 29 Jun 2026 16:04:48 +0100 Subject: [PATCH 08/20] feat(billing): portal current-usage = SaaS-synced + instance-local unsynced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: GET /api/v1/account-link/usage (.local, admin-only) exposes this instance's locally-accrued but not-yet-synced usage per category for the current period (cumulative − lastSyncedUnits, floored at 0; scoped to the entitlement's period so prior-period leftovers don't inflate it). LocalUsageService reads the counters; zeros when metering is off or the period is unknown. FE: the portal fetches it alongside the wallet (best-effort) and folds it into the "PDFs processed this period" card — headline + category split show synced + unsynced as one current-usage figure, with a "+N pending sync" note. Spend/cap cards stay on the Stripe-authoritative figures. - proprietary: LocalUsageService + GET /usage + UsageCounter 5-arg ctor + tests - portal: fetchLocalUsage + LocalUsage type + MSW handler/fixture + PdfsProcessedCard combine + story + link.test + en-US pendingSync copy --- .../accountlink/AccountLinkController.java | 16 +++- .../accountlink/LocalUsageService.java | 62 +++++++++++++++ .../proprietary/accountlink/UsageCounter.java | 11 +++ .../AccountLinkControllerTest.java | 2 +- .../accountlink/LocalUsageServiceTest.java | 75 +++++++++++++++++++ .../public/locales/en-US/translation.toml | 2 + frontend/portal/src/api/link.test.ts | 11 +++ frontend/portal/src/api/link.ts | 11 +++ .../billing/PdfsProcessedCard.stories.tsx | 14 ++++ .../components/billing/PdfsProcessedCard.tsx | 35 ++++++++- .../components/billing/SubscribedPlanView.tsx | 11 ++- frontend/portal/src/mocks/handlers/link.ts | 6 ++ frontend/portal/src/mocks/link.ts | 34 +++++++++ frontend/portal/src/views/Usage.tsx | 20 ++++- 14 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java 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..2119dc2d09 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 @@ -23,7 +23,8 @@ *

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. * *

Admin-only, {@code @Profile("!saas")}, gated behind {@code * stirling.billing.account-link.enabled} — off → bean absent → 404. @@ -38,9 +39,11 @@ public class AccountLinkController { private final AccountLinkService service; + private final LocalUsageService localUsageService; - public AccountLinkController(AccountLinkService service) { + public AccountLinkController(AccountLinkService service, LocalUsageService localUsageService) { this.service = service; + this.localUsageService = localUsageService; } /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ @@ -85,4 +88,13 @@ public ResponseEntity unlink() { service.unlink(); return ResponseEntity.noContent().build(); } + + /** + * Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend + * so "current usage" includes work done since the last daily sync. + */ + @GetMapping("/usage") + public ResponseEntity usage() { + return ResponseEntity.ok(localUsageService.currentPeriodUnsynced()); + } } 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..1b8917eb1f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +/** + * Reads this instance's locally accrued but not-yet-synced usage for the current period + * (combined-billing "Mode A"). The portal adds this on top of the SaaS-synced spend so "current + * usage" reflects work done since the last daily sync, not just what SaaS has already billed. + * + *

Unsynced per category = {@code cumulativeUnits − lastSyncedUnits} (floored at 0). Scoped to + * the entitlement's current period so prior-period leftovers (reported separately on rollover) + * don't inflate the current figure. Returns zeros when the period is unknown or metering is off (no + * counters accrue). + */ +@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); + } + long api = 0; + long ai = 0; + long automation = 0; + for (UsageCounter c : counters.findByPeriodStart(period)) { + long unsynced = Math.max(0, c.getCumulativeUnits() - c.getLastSyncedUnits()); + switch (c.getCategory()) { + case "API" -> api = unsynced; + case "AI" -> ai = unsynced; + case "AUTOMATION" -> automation = unsynced; + default -> { + // BYPASSED never accrues; ignore any unexpected category. + } + } + } + return new LocalUsage(period, api, ai, automation, api + ai + automation); + } +} 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 index b8fe1eeedd..af5a6c0b17 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -64,14 +64,25 @@ public class UsageCounter { @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; } } 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..405869f11c 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 @@ -26,7 +26,7 @@ class AccountLinkControllerTest { @BeforeEach void setUp() { service = mock(AccountLinkService.class); - controller = new AccountLinkController(service); + controller = new AccountLinkController(service, mock(LocalUsageService.class)); } @Test 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/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index c0b3ccae91..1601290b94 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -1742,6 +1742,8 @@ segmentAutomationDesc = "Automations & pipelines" legendValue_one = "{{formatted}} PDFs" legendValue_other = "{{formatted}} PDFs" emptyPeriod = "No metered processing yet this period." +pendingSync_one = "+{{formatted}} processed locally, pending sync" +pendingSync_other = "+{{formatted}} processed locally, pending sync" [billing.spendThisMonth] eyebrow = "Spend this month" diff --git a/frontend/portal/src/api/link.test.ts b/frontend/portal/src/api/link.test.ts index e6d9b12703..52eb1cf391 100644 --- a/frontend/portal/src/api/link.test.ts +++ b/frontend/portal/src/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/portal/src/api/link.ts b/frontend/portal/src/api/link.ts index c55978dffc..bb6f83b6a8 100644 --- a/frontend/portal/src/api/link.ts +++ b/frontend/portal/src/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 diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx b/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx index eaf3172bec..6f4c46641c 100644 --- a/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx +++ b/frontend/portal/src/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/portal/src/components/billing/PdfsProcessedCard.tsx b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx index 1718ea2a19..3bbfd815d6 100644 --- a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx +++ b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import { Card } from "@shared/components"; 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,10 @@ 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, with a "pending sync" note for honesty. */ const SEGMENTS: ReadonlyArray<{ key: keyof WalletCategoryBreakdown; @@ -35,10 +40,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 ( @@ -47,13 +67,22 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {

- {wallet.billableUsed.toLocaleString()} + {headline.toLocaleString()} {t("billing.pdfsProcessed.unit")}
+ {pending > 0 && ( +

+ {t("billing.pdfsProcessed.pendingSync", { + count: pending, + formatted: pending.toLocaleString(), + })} +

+ )} + {total > 0 ? ( <>
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); @@ -77,7 +84,7 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) { - +
diff --git a/frontend/portal/src/mocks/handlers/link.ts b/frontend/portal/src/mocks/handlers/link.ts index 2be110a794..8b604eb3da 100644 --- a/frontend/portal/src/mocks/handlers/link.ts +++ b/frontend/portal/src/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. diff --git a/frontend/portal/src/mocks/link.ts b/frontend/portal/src/mocks/link.ts index f540f85177..0dd11e104c 100644 --- a/frontend/portal/src/mocks/link.ts +++ b/frontend/portal/src/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/portal/src/views/Usage.tsx b/frontend/portal/src/views/Usage.tsx index 172b88c3cf..8b358d01b5 100644 --- a/frontend/portal/src/views/Usage.tsx +++ b/frontend/portal/src/views/Usage.tsx @@ -4,6 +4,7 @@ import { Banner, Button, Skeleton } from "@shared/components"; import { useLink } from "@portal/contexts/LinkContext"; import { useUI } from "@portal/contexts/UIContext"; import { fetchWallet, type Wallet } from "@portal/api/billing"; +import { fetchLocalUsage, 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,6 +36,9 @@ 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 @@ -60,6 +64,7 @@ export function Usage() { // link prompt; no SaaS call needed. if (!isLinked) { setWallet(null); + setLocalUsage(null); setLoading(false); setError(null); setNeedsReauth(false); @@ -69,6 +74,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; @@ -203,7 +217,11 @@ export function Usage() { )} {isLinked && wallet && wallet.status === "subscribed" && ( - + )}
From abaa90715866c6c819e6f603825add777c7ade64 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Tue, 30 Jun 2026 10:01:56 +0100 Subject: [PATCH 09/20] =?UTF-8?q?feat(billing):=20slice=204=20hardening=20?= =?UTF-8?q?=E2=80=94=20PDF=20page-counting=20+=20per-sync=20anomaly=20boun?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page-counting (proprietary): the metering interceptor now counts PDF pages (PDFBox, bounded to 50MB, malformed/encrypted → bytes-only fallback) instead of the bytes axis alone. The instance is authoritative for units — SaaS bills the delta of what we report and never sees the file — so a page-heavy but small PDF was under-billed before. Test builds a real 5-page PDF and asserts 5 units, not 1. Anomaly bound (SaaS ingest): a per-category, per-sync ceiling (max-units-per-sync, default 100000) refuses an implausible delta — likely a runaway instance bug — rather than silently over-charging; it's logged and not advanced so a corrected resend can reconcile. Complements the existing regression-refusal and monotonic-seq replay guard. (HMAC payload signing intentionally NOT added — see follow-up note: with the device secret riding in the same request over TLS it adds nothing; real payload integrity needs sign-don't-transmit, e.g. Ed25519, a separate auth-model change.) --- .../InstanceEntitlementInterceptor.java | 45 +++++++++++++++-- .../InstanceEntitlementInterceptorTest.java | 50 +++++++++++++++++++ .../instance/InstanceUsageIngestService.java | 26 +++++++++- .../InstanceUsageIngestServiceTest.java | 15 +++++- 4 files changed, 130 insertions(+), 6 deletions(-) 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 c329d17d00..f9e3233c56 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,8 +1,11 @@ package stirling.software.proprietary.accountlink; +import java.io.IOException; import java.util.ArrayList; import java.util.List; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; @@ -127,10 +130,15 @@ public void afterCompletion( } } + // Bytes above this aren't page-counted: parsing a huge upload on the metering path isn't worth + // it, and the byte axis already dominates the unit count at that size. + private static final long MAX_BYTES_FOR_PAGE_COUNT = 50L * 1024 * 1024; + /** - * Doc-units for this request via the shared calculator. Counts uploaded file bytes; page counts - * are not read here yet (a follow-up will materialise PDFs for the page axis), so this is the - * bytes-axis lower bound — a non-file billable op costs 1 unit. + * Doc-units for this request via the shared calculator, on both the page and byte axes. The + * instance is authoritative for units here — SaaS bills the delta of what we report and never + * sees the file — so a page-heavy but small PDF must be page-counted or it under-bills. A + * non-file billable op costs 1 unit. */ private static long computeUnits(HttpServletRequest request, UnitCalcPolicy policy) { MultipartHttpServletRequest mreq = @@ -141,11 +149,40 @@ private static long computeUnits(HttpServletRequest request, UnitCalcPolicy poli List sizes = new ArrayList<>(); for (List files : mreq.getMultiFileMap().values()) { for (MultipartFile f : files) { - sizes.add(new FileSize(0, f.getSize())); + sizes.add(new FileSize(pageCount(f), f.getSize())); } } return sizes.isEmpty() ? DocumentUnitCalculator.unitsForFile(0, 0, policy) : DocumentUnitCalculator.unitsForGroup(sizes, policy); } + + /** + * Page count for a PDF upload, or 0 for non-PDFs / oversized / unreadable inputs — the byte + * axis still produces a charge, matching the SaaS classifier's malformed-PDF fallback. + */ + private static int pageCount(MultipartFile file) { + long size = file.getSize(); + if (!isPdf(file) || size <= 0 || size > MAX_BYTES_FOR_PAGE_COUNT) { + return 0; + } + try (PDDocument doc = Loader.loadPDF(file.getBytes())) { + return doc.getNumberOfPages(); + } catch (IOException | RuntimeException e) { + // Malformed / encrypted / already-consumed part → fall back to the byte axis only. + log.debug( + "Page count unavailable for {}; metering on bytes only", + file.getOriginalFilename()); + return 0; + } + } + + 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/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java index 9c8d333ee1..7f6b023ad0 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 @@ -10,9 +10,12 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; 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.mockito.Mock; @@ -21,6 +24,8 @@ 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.proprietary.billing.BillingCategory; import stirling.software.proprietary.billing.UnitCalcPolicy; @@ -107,6 +112,51 @@ void metersSuccessfulBillableOp() throws Exception { verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L)); } + @Test + void metersPdfByPageCountNotJustBytes() 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 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( + new InstanceEntitlement( + true, + 0, + 0, + 100L, + EntitlementState.OK, + policy, + period, + period.plusMonths(1)))); + + 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); + + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L)); + } + + 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(); + } + } + @Test void doesNotMeterWhenMeteringSwitchOff() throws Exception { when(gate.evaluate(anyBoolean())) 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 index d53f553f98..fd0b253345 100644 --- 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 @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.Map; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -28,6 +29,11 @@ * Stripe meter, and idempotency the in-cloud charge path uses — so there is no separate billing * logic for this flow. * + *

A per-category, per-sync upper bound ({@code max-units-per-sync}) guards against a runaway + * instance bug billing the customer for an implausible jump: an over-bound delta is refused (not + * billed, not advanced) and logged, so it surfaces for review rather than silently over-charging, + * and a corrected resend can reconcile once the threshold is understood. + * *

Gated behind {@code stirling.billing.account-link.enabled}. */ @Slf4j @@ -38,11 +44,16 @@ public class InstanceUsageIngestService { private final PaygInstanceUsageRepository usageRepository; private final JobChargeService chargeService; + private final long maxUnitsPerSync; public InstanceUsageIngestService( - PaygInstanceUsageRepository usageRepository, JobChargeService chargeService) { + PaygInstanceUsageRepository usageRepository, + JobChargeService chargeService, + @Value("${stirling.billing.account-link.max-units-per-sync:100000}") + long maxUnitsPerSync) { this.usageRepository = usageRepository; this.chargeService = chargeService; + this.maxUnitsPerSync = maxUnitsPerSync; } /** @@ -110,6 +121,19 @@ private void applyCategory( lastCumulative); return; } + if (delta > maxUnitsPerSync) { + // Implausible jump (likely a runaway instance bug). Refuse rather than over-bill the + // customer; don't advance, so it stays visible for review and a corrected resend can + // reconcile once understood. + log.warn( + "Instance usage anomaly team={} category={} delta {} exceeds max-units-per-sync" + + " {}; refusing to bill until reviewed.", + teamId, + category, + delta, + maxUnitsPerSync); + return; + } if (delta > 0) { int units = (int) Math.min(delta, Integer.MAX_VALUE); chargeService.chargeStandalone( 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 index f2e9133523..b456fcb437 100644 --- 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 @@ -34,10 +34,11 @@ class InstanceUsageIngestServiceTest { private InstanceUsageIngestService service; private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + private static final long MAX_UNITS_PER_SYNC = 1000L; @BeforeEach void setUp() { - service = new InstanceUsageIngestService(repo, chargeService); + service = new InstanceUsageIngestService(repo, chargeService, MAX_UNITS_PER_SYNC); } @Test @@ -111,6 +112,18 @@ void zeroDeltaAdvancesSeqWithoutCharging() { assertThat(existing.getLastSyncSeq()).isEqualTo(3L); } + @Test + void overBoundDeltaIsRefusedAndNotAdvanced() { + when(repo.findByTeamIdAndPeriodStartAndCategory(1L, period, "API")) + .thenReturn(Optional.empty()); + + // First sync, cumulative far above the per-sync ceiling → refuse + don't advance. + service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.API, MAX_UNITS_PER_SYNC + 1)); + + verify(chargeService, never()).chargeStandalone(any(), anyInt()); + verify(repo, never()).save(any()); + } + @Test void nullActorSkipsEntirely() { service.ingest(1L, null, 1L, period, Map.of(BillingCategory.AI, 10L)); From d13b493b65bba469040a1cbb4ecabc195f2ee706 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Tue, 30 Jun 2026 12:02:06 +0100 Subject: [PATCH 10/20] fix(billing): address multi-agent review of the daily-sync metering PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the max-units-per-sync anomaly bound (the right limit is the customer's cap, enforced at the gate — an arbitrary per-sync ceiling both stranded the usage permanently and didn't actually stop a catastrophic bill). Removing it fixes the stranding bug the review found: SaaS refused an over-bound delta without advancing, but /sync still returned 200 so the instance marked it synced and never re-reported → usage lost forever. Review fixes: - Drop anomaly bound; document that cap enforcement is the gate's job, not the ingest's (mirrors in-cloud EntitlementGuard vs JobChargeService), + intent test - Pessimistic row lock on the ingest read-modify-write so a duplicate sync delivery can't double-charge the delta (mirrors the free-grant findByIdForUpdate) - Validate the request's periodStart against the authoritative snapshot period (reject a fabricated value that would reset the dedup partition) + reject test - Bind the sync interval in code via SchedulingConfigurer instead of a @Scheduled SpEL string (was only evaluated at flags-on boot, zero CI coverage) + interval test - columnDefinition default 0 on the durable instance counters so ddl-auto ADD COLUMN is safe on a populated external Postgres - Document minChargeUnits per-sync-delta semantics (vs per-op in-cloud) - V25: add the "-- Twin of" Supabase header (twin file lands in the SaaS repo) --- .../accountlink/AccountLinkSyncState.java | 6 +- .../proprietary/accountlink/UsageCounter.java | 9 ++- .../accountlink/UsageSyncService.java | 33 ++++++++--- .../accountlink/UsageSyncServiceTest.java | 27 ++++++++- .../saas/accountlink/InstanceController.java | 17 ++++++ .../instance/InstanceUsageIngestService.java | 56 +++++++++---------- .../PaygInstanceUsageRepository.java | 20 +++++++ .../saas/V25__payg_instance_usage.sql | 5 ++ .../accountlink/InstanceControllerTest.java | 38 ++++++++++++- .../InstanceUsageIngestServiceTest.java | 28 +++++----- 10 files changed, 183 insertions(+), 56 deletions(-) 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 index a541d260ca..fbac6a8603 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java @@ -33,7 +33,11 @@ public class AccountLinkSyncState { @Id private Long id; - @Column(name = "last_sync_seq", nullable = false) + // 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. */ 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 index af5a6c0b17..3ae67f322e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -57,8 +57,15 @@ public class UsageCounter { /** * Value of {@link #cumulativeUnits} as of the last sync SaaS accepted. {@code cumulativeUnits − * lastSyncedUnits} is the as-yet-unreported usage the portal shows on top of SaaS-synced spend. + * + *

{@code columnDefinition} default keeps the {@code ddl-auto=update} ADD COLUMN safe if an + * earlier build already populated this table on an external Postgres (NOT NULL with no default + * would otherwise fail the ALTER). */ - @Column(name = "last_synced_units", nullable = false) + @Column( + name = "last_synced_units", + nullable = false, + columnDefinition = "bigint not null default 0") private long lastSyncedUnits; @Column(name = "updated_at", nullable = false) 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 index bce10329ca..e51250601d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -1,12 +1,15 @@ package stirling.software.proprietary.accountlink; +import java.time.Duration; import java.time.LocalDateTime; 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.Scheduled; +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; @@ -31,32 +34,46 @@ @ConditionalOnProperty( name = "stirling.billing.account-link.metering.enabled", havingValue = "true") -public class UsageSyncService { +public class UsageSyncService implements SchedulingConfigurer { + + // First run waits out startup (entitlement fetch + any restart 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) { + 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 with the interval bound from {@code metering.sync-interval-hours} in + * code rather than a {@code @Scheduled} SpEL string — a bad interval is then a unit-test/boot + * failure, not a flags-on-bootRun-only surprise. {@code @EnableScheduling} (on the app) drives + * this. + */ + @Override + public void configureTasks(ScheduledTaskRegistrar registrar) { + Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours()); + registrar.addFixedDelayTask( + new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY)); } - // First run waits out startup (entitlement fetch + any restart churn); then every interval. - @Scheduled( - initialDelay = 300_000L, - fixedDelayString = - "#{T(java.time.Duration).ofHours(${stirling.billing.account-link.metering.sync-interval-hours:24}).toMillis()}") public void scheduledSync() { try { syncNow(); 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 index 47bab5ed02..e2c94d31e0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java @@ -1,5 +1,6 @@ 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; @@ -9,6 +10,7 @@ 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; @@ -18,6 +20,7 @@ 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 { @@ -35,7 +38,29 @@ class UsageSyncServiceTest { void setUp() { service = new UsageSyncService( - counters, syncState, credentialStore, client, entitlementCache); + 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() { 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 f1d53b6dc8..dba36d8313 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 @@ -154,6 +154,23 @@ public ResponseEntity sync( return ResponseEntity.badRequest().build(); } Long teamId = token.getTeamId(); + // periodStart is the (team, period, category) partition key the dedup/regression guards key + // on, so a fabricated value could reset those guards. Bound it to a plausible window around + // the authoritative snapshot period — the current period or the immediately-prior one + // (rollover lag), never the future — rejecting anything else. + 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 = 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 index fd0b253345..8c6ffcb093 100644 --- 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 @@ -3,7 +3,6 @@ import java.time.LocalDateTime; import java.util.Map; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -24,15 +23,26 @@ *

The instance reports a monotonic cumulative unit total per {@link BillingCategory} for * the current billing period. We bill only the delta since the last sync, which makes the * model idempotent (a resend reports the same total → delta 0 → no charge) and tamper-evident (a - * total that goes backwards is refused, not credited). The charge itself reuses {@link - * JobChargeService#chargeStandalone} — the same free-grant split, {@code wallet_ledger} DEBIT, - * Stripe meter, and idempotency the in-cloud charge path uses — so there is no separate billing - * logic for this flow. + * total that goes backwards is refused, not credited; a monotonic {@code syncSeq} dedups replays). + * The charge itself reuses {@link JobChargeService#chargeStandalone} — the same free-grant + * split, {@code wallet_ledger} DEBIT, Stripe meter, and idempotency the in-cloud charge path uses — + * so there is no separate billing logic for this flow. * - *

A per-category, per-sync upper bound ({@code max-units-per-sync}) guards against a runaway - * instance bug billing the customer for an implausible jump: an over-bound delta is refused (not - * billed, not advanced) and logged, so it surfaces for review rather than silently over-charging, - * and a corrected resend can reconcile once the threshold is understood. + *

Cap enforcement is the request-time gate's job, not this charge path's — exactly as the + * in-cloud path enforces the cap at {@code EntitlementGuard}, not in {@code JobChargeService}. The + * instance's own {@code InstanceEntitlementGate} blocks billable work once the team is over its cap + * (a $0 cap blocks everything metered), so usage stops accruing at the cap and the reported delta + * does not run past it. We deliberately do NOT re-check the cap here: a customer is never charged + * past a limit their gate already enforces, and the only residual is the bounded + * (~entitlement-cache TTL) overshoot inherent to any eventually-consistent meter. If the instance + * ever meters past the cap that is an instance bug to fix, not something this aggregate path should + * silently absorb. + * + *

{@code minChargeUnits} is applied by {@code chargeStandalone} per sync-delta here, + * which intentionally differs from the per-operation floor in-cloud: the cumulative-delta model + * carries no per-op identity, so a daily delta of D bills {@code max(D, minChargeUnits)} once, not + * per underlying op. With the shipped default ({@code minChargeUnits=1}) this is a no-op (the + * delta>0 guard already covers the only floored case). * *

Gated behind {@code stirling.billing.account-link.enabled}. */ @@ -44,16 +54,11 @@ public class InstanceUsageIngestService { private final PaygInstanceUsageRepository usageRepository; private final JobChargeService chargeService; - private final long maxUnitsPerSync; public InstanceUsageIngestService( - PaygInstanceUsageRepository usageRepository, - JobChargeService chargeService, - @Value("${stirling.billing.account-link.max-units-per-sync:100000}") - long maxUnitsPerSync) { + PaygInstanceUsageRepository usageRepository, JobChargeService chargeService) { this.usageRepository = usageRepository; this.chargeService = chargeService; - this.maxUnitsPerSync = maxUnitsPerSync; } /** @@ -101,9 +106,15 @@ private void applyCategory( LocalDateTime periodStart, BillingCategory category, long cumulative) { + // Pessimistic row lock: an external duplicate delivery of the same sync (e.g. a proxy + // retry) would otherwise let two transactions read the same baseline and both charge the + // delta. Locking serialises them — the second sees the advanced seq and replay-skips. A + // first-insert race is caught by uk_payg_instance_usage (the losing txn rolls back + the + // instance retries next sync). PaygInstanceUsage row = usageRepository - .findByTeamIdAndPeriodStartAndCategory(teamId, periodStart, category.name()) + .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 @@ -121,19 +132,6 @@ private void applyCategory( lastCumulative); return; } - if (delta > maxUnitsPerSync) { - // Implausible jump (likely a runaway instance bug). Refuse rather than over-bill the - // customer; don't advance, so it stays visible for review and a corrected resend can - // reconcile once understood. - log.warn( - "Instance usage anomaly team={} category={} delta {} exceeds max-units-per-sync" - + " {}; refusing to bill until reviewed.", - teamId, - category, - delta, - maxUnitsPerSync); - return; - } if (delta > 0) { int units = (int) Math.min(delta, Integer.MAX_VALUE); chargeService.chargeStandalone( 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 index 9836c616e0..283cc212b1 100644 --- 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 @@ -4,6 +4,11 @@ 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; @@ -12,4 +17,19 @@ public interface PaygInstanceUsageRepository extends JpaRepository 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 index dfa5d255a5..7ab65b8b78 100644 --- 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 @@ -1,3 +1,8 @@ +-- 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 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 a9bbbeb936..b7e3f5e2da 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 @@ -144,13 +144,13 @@ 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)); - when(entitlementService.getSnapshot(99L)) - .thenReturn(snapshot(EntitlementState.FULL, 5L, null)); + // The reported periodStart is validated against the authoritative snapshot period. + when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null)); when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy()); - LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); InstanceController.UsageSyncRequest req = new InstanceController.UsageSyncRequest( 3L, @@ -170,6 +170,25 @@ void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() { .containsEntry(BillingCategory.AUTOMATION, 8L); } + @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); @@ -247,4 +266,17 @@ private static EntitlementSnapshot snapshot(EntitlementState state, long spend, start.plusMonths(1), false); } + + /** Snapshot with an explicit period — the sync tests need a deterministic period window. */ + private static EntitlementSnapshot snapshotForPeriod(LocalDateTime start, Long cap) { + return new EntitlementSnapshot( + EntitlementState.FULL, + FeatureSet.FULL, + List.of(FeatureGate.OFFSITE_PROCESSING), + 0L, + cap, + start, + start.plusMonths(1), + false); + } } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java index b456fcb437..57c7b42c01 100644 --- 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 @@ -34,16 +34,15 @@ class InstanceUsageIngestServiceTest { private InstanceUsageIngestService service; private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); - private static final long MAX_UNITS_PER_SYNC = 1000L; @BeforeEach void setUp() { - service = new InstanceUsageIngestService(repo, chargeService, MAX_UNITS_PER_SYNC); + service = new InstanceUsageIngestService(repo, chargeService); } @Test void firstSyncChargesFullCumulativeAndSavesRow() { - when(repo.findByTeamIdAndPeriodStartAndCategory(1L, period, "AI")) + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "AI")) .thenReturn(Optional.empty()); service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.AI, 10L)); @@ -64,11 +63,13 @@ void firstSyncChargesFullCumulativeAndSavesRow() { @Test void secondSyncChargesOnlyDelta() { PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 10L, 1L); - when(repo.findByTeamIdAndPeriodStartAndCategory(1L, period, "API")) + 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); @@ -78,7 +79,7 @@ void secondSyncChargesOnlyDelta() { @Test void replayIsIgnored() { PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); - when(repo.findByTeamIdAndPeriodStartAndCategory(1L, period, "API")) + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) .thenReturn(Optional.of(existing)); service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L)); @@ -90,7 +91,7 @@ void replayIsIgnored() { @Test void regressionIsRefusedAndNotAdvanced() { PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); - when(repo.findByTeamIdAndPeriodStartAndCategory(1L, period, "API")) + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) .thenReturn(Optional.of(existing)); service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 5L)); @@ -102,7 +103,7 @@ void regressionIsRefusedAndNotAdvanced() { @Test void zeroDeltaAdvancesSeqWithoutCharging() { PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); - when(repo.findByTeamIdAndPeriodStartAndCategory(1L, period, "API")) + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) .thenReturn(Optional.of(existing)); service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 25L)); @@ -113,15 +114,16 @@ void zeroDeltaAdvancesSeqWithoutCharging() { } @Test - void overBoundDeltaIsRefusedAndNotAdvanced() { - when(repo.findByTeamIdAndPeriodStartAndCategory(1L, period, "API")) + 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()); - // First sync, cumulative far above the per-sync ceiling → refuse + don't advance. - service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.API, MAX_UNITS_PER_SYNC + 1)); + service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.API, 5_000_000L)); - verify(chargeService, never()).chargeStandalone(any(), anyInt()); - verify(repo, never()).save(any()); + verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(5_000_000)); } @Test From 8a36e0fb7482a10c3ae985f577d149ee8e83b66e Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Tue, 30 Jun 2026 14:18:43 +0100 Subject: [PATCH 11/20] =?UTF-8?q?fix(billing):=20second-review=20round=201?= =?UTF-8?q?=20=E2=80=94=20grace=20window,=20minCharge=20test,=20ObjectMapp?= =?UTF-8?q?er=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three findings from the external PR review (all behind the off-by-default flags): - F1 (MED): wire the grace window the config always promised but nothing read. InstanceEntitlementGate now blocks (GRACE_EXPIRED) instead of failing open forever when linked + metering on + SaaS unreachable past graceDays. Reference is the persisted last-successful sync (survives restart), falling back to linkedAt for a never-synced instance; metering off or graceDays<=0 disables the backstop (so it stays inert in release where the whole model is off). This makes the "the gate is the backstop" rationale (used to drop the anomaly bound) real. - F4 (LOW): add a chargeStandalone minChargeUnits>1 regression test pinning the per-sync-delta floor. (The review's "over-charge" framing is inverted — per-delta flooring is always <= per-op, i.e. under-bills or equals, never over-charges.) - F5d (nit): build the /sync body with ObjectMapper (ObjectNode) not string concat. --- .../accountlink/AccountLinkClient.java | 23 ++-- .../proprietary/accountlink/GateDecision.java | 5 + .../accountlink/InstanceEntitlementGate.java | 65 +++++++++-- .../InstanceEntitlementGateTest.java | 110 +++++++++++++++--- .../InstanceEntitlementGateWiringTest.java | 4 +- .../payg/charge/JobChargeServiceTest.java | 46 ++++++++ 6 files changed, 216 insertions(+), 37 deletions(-) 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 36267a9ec3..b92bb0ac1d 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 @@ -19,6 +19,7 @@ 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 @@ -245,18 +246,16 @@ public InstanceEntitlement reportUsage( long automationUnits) { HttpResponse response; try { - String body = - "{\"syncSeq\":" - + syncSeq - + ",\"periodStart\":\"" - + periodStart - + "\",\"cumulativeUnits\":{\"api\":" - + apiUnits - + ",\"ai\":" - + aiUnits - + ",\"automation\":" - + automationUnits - + "}}"; + ObjectNode root = mapper.createObjectNode(); + root.put("syncSeq", syncSeq); + // periodStart as an explicit ISO-8601 string so it round-trips to the SaaS + // LocalDateTime regardless of the mapper's time-module 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")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java index 677183278b..87b4ddcde9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java @@ -16,6 +16,11 @@ public enum Reason { ENTITLED, /** Entitlement source unreachable — fail open, allow. */ FAIL_OPEN, + /** + * Linked + metering, but SaaS has been unreachable past the grace window — block (the + * fail-open backstop expired) so unbounded free/unbilled billable work can't continue. + */ + GRACE_EXPIRED, /** Not linked — block billable work; FE should prompt to link. */ NOT_LINKED, /** Linked but over the limit / no subscription — block billable work. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java index c975bad055..66c6590c16 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.accountlink; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -15,14 +16,17 @@ *

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

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

    The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live + * flag / linked-state / entitlement and computes whether the grace window has expired. This is the + * unit-tested core. */ @Service @Profile("!saas") @@ -32,14 +36,17 @@ public class InstanceEntitlementGate { private final AccountLinkProperties properties; private final DeviceCredentialStore credentialStore; private final EntitlementCache entitlementCache; + private final AccountLinkSyncStateRepository syncStateRepository; public InstanceEntitlementGate( AccountLinkProperties properties, DeviceCredentialStore credentialStore, - EntitlementCache entitlementCache) { + EntitlementCache entitlementCache, + AccountLinkSyncStateRepository syncStateRepository) { this.properties = properties; this.credentialStore = credentialStore; this.entitlementCache = entitlementCache; + this.syncStateRepository = syncStateRepository; } /** Evaluates the gate for a request, resolving live state from the store + cache. */ @@ -53,18 +60,21 @@ public GateDecision evaluate(boolean billable) { boolean linked = credentialStore.isLinked(); Optional entitlement = linked ? entitlementCache.current() : Optional.empty(); - return decide(true, true, linked, entitlement); + boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired(); + return decide(true, true, linked, entitlement, graceExpired); } /** * 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. */ public static GateDecision decide( boolean flagEnabled, boolean billable, boolean linked, - Optional entitlement) { + Optional entitlement, + boolean graceExpired) { if (!flagEnabled) { return GateDecision.allow(GateDecision.Reason.FLAG_OFF); } @@ -75,9 +85,13 @@ public static GateDecision decide( return GateDecision.block(GateDecision.Reason.NOT_LINKED); } if (entitlement.isEmpty()) { - // Linked but entitlement source unreachable — never hard-block billable work on our - // inability to reach billing. - return GateDecision.allow(GateDecision.Reason.FAIL_OPEN); + // Linked but entitlement source unreachable. Normally fail open (never hard-block on + // our + // inability to reach billing) — but once the grace window has expired, block so the + // fail-open can't grant unbounded free/unbilled billable work indefinitely. + return graceExpired + ? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED) + : GateDecision.allow(GateDecision.Reason.FAIL_OPEN); } InstanceEntitlement e = entitlement.get(); if (e.state() == EntitlementState.REVOKED) { @@ -89,6 +103,37 @@ public static GateDecision decide( : GateDecision.block(GateDecision.Reason.OVER_LIMIT); } + /** + * True when metering is on and SaaS has been unreachable past the grace window — i.e. it's been + * {@code graceDays} since the last authoritative contact. The reference is the last successful + * daily sync (persisted, survives restart), falling back to the link time for a never-synced + * instance. {@code graceDays <= 0} disables the backstop; metering off never blocks (nothing + * accrues, so a stale sync must not gate manual-free or pre-metering work). + */ + 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) { if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) { 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..1becab0f02 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,31 @@ 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; + private static InstanceEntitlement free() { return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK); } @@ -35,35 +46,45 @@ private static InstanceEntitlement subscribedOverCap() { @Test void flagOff_allowsEverything_evenBillableUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false); 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); 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); 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); 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); + 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); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -72,7 +93,7 @@ void billable_linked_freePoolAvailable_allows() { void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(exhaustedUnsubscribed())); + true, true, true, Optional.of(exhaustedUnsubscribed()), false); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -81,7 +102,7 @@ void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { void billable_linked_subscribedWithinCap_allows() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(subscribedWithinCap())); + true, true, true, Optional.of(subscribedWithinCap()), false); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -89,7 +110,8 @@ void billable_linked_subscribedWithinCap_allows() { @Test void billable_linked_subscribedOverCap_blocks() { GateDecision d = - InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap())); + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedOverCap()), false); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -100,7 +122,8 @@ void billable_linked_revoked_blocksWithRevokedSignal() { // 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); assertFalse(d.allowed()); assertEquals(Reason.REVOKED, d.reason()); } @@ -110,8 +133,67 @@ void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() { // Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive. InstanceEntitlement conflicting = new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting)); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting), false); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } + + // --- grace window (evaluate()) --------------------------------------------------------------- + + private InstanceEntitlementGate gate(AccountLinkProperties props) { + return new InstanceEntitlementGate( + props, credentialStore, entitlementCache, syncStateRepository); + } + + 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()); + } } 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..f3dca44266 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 @@ -27,7 +27,9 @@ void setUp() { properties.setEnabled(true); store = mock(DeviceCredentialStore.class); cache = mock(EntitlementCache.class); - gate = new InstanceEntitlementGate(properties, store, cache); + gate = + new InstanceEntitlementGate( + properties, store, cache, mock(AccountLinkSyncStateRepository.class)); } @Test diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java index 8b3ce50b6a..e669e1b5bd 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java @@ -913,6 +913,52 @@ void chargeStandalone_bypassedCategory_throws() { .isInstanceOf(IllegalArgumentException.class); } + @Test + void chargeStandalone_floorsUnitsAtMinChargeUnits() { + // Pins the per-call minChargeUnits floor that the linked-instance sync path inherits: a + // daily delta below the floor bills the floor (max(delta, minChargeUnits)) — applied per + // sync-delta here, not per underlying op (documented divergence from the in-cloud per-op + // floor; can only under-bill vs per-op, never over). + long teamId = 100L; + PricingPolicy policy = stubPolicy(/*minCharge*/ 5, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(teamId)).thenReturn(policy); + + UUID jobId = UUID.randomUUID(); + when(jobService.open(any(JobContext.class), eq(5))).thenReturn(openJob(jobId)); + when(jobService.close(jobId)).thenReturn(openJob(jobId)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(teamId); + ext.setStripeCustomerId("cus_x"); + ext.setPaygSubscriptionId("sub_x"); + ext.setFreeUnitsRemaining(0L); + when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext)); + when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext)); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)) + .thenReturn( + Optional.of(chargedShadowRow(jobId, teamId, 5, 0, BillingCategory.API))); + + ChargeContext ctx = + new ChargeContext( + 7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API); + ArgumentCaptor ledger = ArgumentCaptor.forClass(WalletLedgerEntry.class); + + withTransactionSynchronization(() -> service.chargeStandalone(ctx, 2)); + + // Delta of 2 floored to minChargeUnits=5: the job, ledger debit, and meter all use 5. + verify(jobService).open(any(JobContext.class), eq(5)); + verify(ledgerRepo).save(ledger.capture()); + assertThat(ledger.getValue().getAmountUnits()).isEqualTo(-5); + verify(meterReporter) + .recordUsage( + eq(teamId), + eq("cus_x"), + eq(5), + eq(BillingCategory.API), + eq("process:" + jobId + ":close"), + eq(jobId)); + } + private static void withTransactionSynchronization(Runnable body) { TransactionSynchronizationManager.initSynchronization(); try { From fd05cefa438582c518756704a414a082c1056597 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Tue, 30 Jun 2026 14:28:21 +0100 Subject: [PATCH 12/20] =?UTF-8?q?fix(billing):=20second-review=20round=202?= =?UTF-8?q?=20=E2=80=94=20jpdfium=20page=20parity=20+=20instance=20lineage?= =?UTF-8?q?=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F2 (MED): the instance now counts PDF pages with jpdfium (parser-identical to the SaaS classifier) instead of PDFBox, so the page axis can't diverge between instance and cloud on encrypted/malformed PDFs. Inputs are materialised once to a temp file and read for both the page count and the content hash; streaming materialisation also retires the 50MB heap-bound page-count cap and the consumed-getBytes risk. F3 (MED): lineage dedup now runs on the instance (reusing SaaS's hashing, adapted to the local DB), so a re-submitted identical input set isn't re-charged — matching the in-cloud lineage join so the same op costs the same wherever it runs. - ContentHasher (shared, :proprietary): the SHA-256 algorithm; SaaS ByteHashSignatureExtractor now delegates to it (single-sourced, no drift). - MeteredInputSignature + repo: per-(period, input-set signature) local dedup store. - UsageMeterService.accrue(..., opSignature): claims the signature (insert-as-claim, race-safe) before incrementing; a claimed signature skips re-accrual. Claim-first means the rare failure is a missed accrual (customer-favourable), never a double-charge. - Interceptor builds the order-independent op signature from sorted per-file hashes; falls back to no-dedup if any input can't be hashed (never a wrong match). --- .../InstanceEntitlementInterceptor.java | 130 +++++++++++++----- .../accountlink/MeteredInputSignature.java | 57 ++++++++ .../MeteredInputSignatureRepository.java | 7 + .../accountlink/UsageMeterService.java | 53 +++++-- .../proprietary/billing/ContentHasher.java | 54 ++++++++ .../InstanceEntitlementInterceptorTest.java | 80 ++++++----- .../accountlink/UsageMeterServiceTest.java | 40 ++++-- .../lineage/ByteHashSignatureExtractor.java | 35 +---- 8 files changed, 335 insertions(+), 121 deletions(-) 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/billing/ContentHasher.java 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 f9e3233c56..030741584f 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,11 +1,15 @@ package stirling.software.proprietary.accountlink; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; import java.util.List; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; @@ -22,7 +26,11 @@ 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; @@ -34,6 +42,12 @@ * manual tools pass straight through. {@code afterCompletion} meters a successful billable * op into the per-period cumulative counter (the daily sync later reports the totals). * + *

    Metering materialises each uploaded input once to a temp file and reads it twice from disk: + * the page count via jpdfium (parser-identical to the SaaS classifier, so the page axis + * can't drift between instance and cloud) and a SHA-256 via the shared {@link ContentHasher} (the + * input-set signature for lineage dedup — a re-submission of the identical inputs isn't re-charged, + * matching the in-cloud lineage join). Either read failing degrades gracefully to bytes-only. + * *

    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. Fail-open and flag-off both let the request continue. @@ -54,14 +68,17 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { private final InstanceEntitlementGate gate; private final EntitlementCache entitlementCache; private final ObjectProvider meterProvider; + private final TempFileManager tempFileManager; public InstanceEntitlementInterceptor( InstanceEntitlementGate gate, EntitlementCache entitlementCache, - ObjectProvider meterProvider) { + ObjectProvider meterProvider, + TempFileManager tempFileManager) { this.gate = gate; this.entitlementCache = entitlementCache; this.meterProvider = meterProvider; + this.tempFileManager = tempFileManager; } @Override @@ -123,53 +140,95 @@ public void afterCompletion( // Not yet synced (no policy/period) — can't compute units; skip until next sync. return; } - meter.accrue(ent.periodStart(), category, computeUnits(request, ent.unitCalcPolicy())); + 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); } } - // Bytes above this aren't page-counted: parsing a huge upload on the metering path isn't worth - // it, and the byte axis already dominates the unit count at that size. - private static final long MAX_BYTES_FOR_PAGE_COUNT = 50L * 1024 * 1024; - /** - * Doc-units for this request via the shared calculator, on both the page and byte axes. The - * instance is authoritative for units here — SaaS bills the delta of what we report and never - * sees the file — so a page-heavy but small PDF must be page-counted or it under-bills. A - * non-file billable op costs 1 unit. + * Computes doc-units (page + byte axes) and the input-set signature for the request, then + * accrues. The instance is authoritative for units — SaaS bills the delta of what we report and + * never sees the file — so a page-heavy but small PDF must be page-counted or it under-bills. A + * fileless billable op has no input identity, so it carries a null signature (no dedup) and is + * billed the 1-unit floor each time. */ - private static long computeUnits(HttpServletRequest request, UnitCalcPolicy policy) { + 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) { - return DocumentUnitCalculator.unitsForFile(0, 0, policy); + meter.accrue( + ent.periodStart(), + category, + DocumentUnitCalculator.unitsForFile(0, 0, policy), + null); + return; } - List sizes = new ArrayList<>(); - for (List files : mreq.getMultiFileMap().values()) { - for (MultipartFile f : files) { - sizes.add(new FileSize(pageCount(f), f.getSize())); + 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); + try (InputStream in = f.getInputStream(); + OutputStream out = Files.newOutputStream(temp.getPath())) { + in.transferTo(out); + } + sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize())); + hashes.add(ContentHasher.sha256(temp.getPath())); + } catch (IOException | RuntimeException perFile) { + // Couldn't materialise/hash this input — bill it on bytes only and (by + // leaving it out of `hashes`) drop dedup for the whole op rather than risk + // a + // wrong match. + 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()); + } } } - return sizes.isEmpty() - ? DocumentUnitCalculator.unitsForFile(0, 0, policy) - : DocumentUnitCalculator.unitsForGroup(sizes, policy); } - /** - * Page count for a PDF upload, or 0 for non-PDFs / oversized / unreadable inputs — the byte - * axis still produces a charge, matching the SaaS classifier's malformed-PDF fallback. - */ - private static int pageCount(MultipartFile file) { - long size = file.getSize(); - if (!isPdf(file) || size <= 0 || size > MAX_BYTES_FOR_PAGE_COUNT) { + /** 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 (PDDocument doc = Loader.loadPDF(file.getBytes())) { - return doc.getNumberOfPages(); - } catch (IOException | RuntimeException e) { - // Malformed / encrypted / already-consumed part → fall back to the byte axis only. + try (PdfDocument doc = PdfDocument.open(path)) { + return doc.pageCount(); + } catch (RuntimeException e) { + // Malformed / encrypted → fall back to the byte axis only, matching the SaaS + // classifier. log.debug( "Page count unavailable for {}; metering on bytes only", file.getOriginalFilename()); @@ -177,6 +236,13 @@ private static int pageCount(MultipartFile file) { } } + /** 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")) { 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..1941e29f47 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java @@ -0,0 +1,57 @@ +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; + +/** + * One {@code (billing period, input-set signature)} the instance has already metered — the local + * equivalent of the cloud's lineage join (combined-billing "Mode A"). Before accruing a billable + * op, the meter claims its op signature here; a re-submission of the identical input set finds the + * signature present and is not re-charged, matching the in-cloud dedup so the same operation + * costs the same whether it runs on the instance or in the cloud. + * + *

    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; + + public MeteredInputSignature( + LocalDateTime periodStart, String signature, LocalDateTime createdAt) { + this.periodStart = periodStart; + this.signature = signature; + this.createdAt = createdAt; + } +} 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..5c462b79b9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.accountlink; + +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 {} 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 index 3271271f57..2098df7de2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java @@ -15,6 +15,12 @@ * Accrues metered usage into the durable per-(period, category) {@link UsageCounter} on the * instance. The daily sync later reports the cumulative totals to SaaS. * + *

    Lineage dedup: a billable op carries an {@code opSignature} (a hash of its input set). Before + * accruing, the meter claims that signature for the period — a re-submission of the + * identical inputs finds it already claimed and is not re-charged, the instance-local equivalent of + * the cloud's lineage join (so the same op costs the same on the instance and in the cloud). + * Fileless ops pass a null signature and always accrue (no input identity to dedup on). + * *

    Gated behind the dedicated {@code stirling.billing.account-link.metering.enabled} switch (on * top of {@code @Profile("!saas")}), so the bean is absent — and nothing accrues — unless billing * is explicitly turned on. {@link #accrue} is best-effort and self-contained: callers (the gate @@ -29,41 +35,68 @@ public class UsageMeterService { private final UsageCounterRepository repo; + private final MeteredInputSignatureRepository signatureRepo; - public UsageMeterService(UsageCounterRepository repo) { + public UsageMeterService( + UsageCounterRepository repo, MeteredInputSignatureRepository signatureRepo) { this.repo = repo; + this.signatureRepo = signatureRepo; } /** * Adds {@code units} to the {@code (periodStart, category)} counter, creating the row on first - * use. Atomic increment-or-insert: an UPDATE first, falling back to an INSERT, and on a lost - * insert race a second increment. No-ops for non-billable categories, non-positive units, or a - * missing period (e.g. entitlement not yet synced). + * use, unless {@code opSignature} (the op's input-set hash) was already metered this period — a + * re-submission of identical inputs, which is skipped. No-ops for non-billable categories, + * non-positive units, or a missing period (e.g. entitlement not yet synced). */ - public void accrue(LocalDateTime periodStart, BillingCategory category, long units) { + public void accrue( + LocalDateTime periodStart, BillingCategory category, long units, String opSignature) { if (periodStart == null || category == null || category == BillingCategory.BYPASSED || units <= 0) { return; } - String cat = category.name(); + // Claim the input-set signature first (insert-as-claim): if it already exists this is a + // re-submission already billed — skip. Claiming before incrementing means the rare failure + // mode is a missed accrual (self-favouring), never a double-charge. + if (opSignature != null && !claimSignature(periodStart, opSignature)) { + return; + } + incrementOrInsert(periodStart, category.name(), units); + } + + private boolean claimSignature(LocalDateTime periodStart, String opSignature) { + try { + signatureRepo.saveAndFlush( + new MeteredInputSignature(periodStart, opSignature, LocalDateTime.now())); + return true; + } catch (DataIntegrityViolationException alreadyMetered) { + return false; // same input set already billed this period + } catch (RuntimeException e) { + // Never let a dedup-store hiccup drop a charge; treat as "not a duplicate" and accrue. + log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage()); + return true; + } + } + + private void incrementOrInsert(LocalDateTime periodStart, String category, long units) { LocalDateTime now = LocalDateTime.now(); try { - if (repo.increment(periodStart, cat, units, now) > 0) { + if (repo.increment(periodStart, category, units, now) > 0) { return; } try { - repo.saveAndFlush(new UsageCounter(periodStart, cat, units, now)); + 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, cat, units, now); + repo.increment(periodStart, category, units, now); } } catch (RuntimeException e) { // Metering must never break the request it rode in on; lost accrual self-heals on the // next operation's increment, and the daily sync reports the cumulative total either // way. - log.debug("Usage accrual failed for {}/{}: {}", periodStart, cat, e.getMessage()); + log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage()); } } } 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..1857d6dd24 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java @@ -0,0 +1,54 @@ +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)); + } + + 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/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java index 7f6b023ad0..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,14 +3,18 @@ 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; @@ -18,6 +22,7 @@ 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; @@ -27,6 +32,8 @@ 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; @@ -36,9 +43,11 @@ 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); + return new InstanceEntitlementInterceptor( + gate, entitlementCache, meterProvider, tempFileManager); } private boolean preHandle(MockHttpServletResponse response) throws Exception { @@ -89,18 +98,7 @@ void metersSuccessfulBillableOp() throws Exception { 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( - new InstanceEntitlement( - true, - 0, - 0, - 100L, - EntitlementState.OK, - policy, - period, - period.plusMonths(1)))); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); InstanceEntitlementInterceptor interceptor = interceptor(); MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); @@ -108,32 +106,26 @@ void metersSuccessfulBillableOp() throws Exception { 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. - verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L)); + // 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() throws Exception { + 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 is what makes this bill correctly. + // 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( - new InstanceEntitlement( - true, - 0, - 0, - 100L, - EntitlementState.OK, - policy, - period, - period.plusMonths(1)))); + 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(); @@ -143,18 +135,8 @@ void metersPdfByPageCountNotJustBytes() throws Exception { interceptor.preHandle(req, resp, new Object()); interceptor.afterCompletion(req, resp, new Object(), null); - verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L)); - } - - 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(); - } + // 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 @@ -172,4 +154,20 @@ void doesNotMeterWhenMeteringSwitchOff() throws Exception { // 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/UsageMeterServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java index a2fa50cd6c..af30d0ab21 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java @@ -1,6 +1,7 @@ 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; @@ -23,20 +24,21 @@ 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); + service = new UsageMeterService(repo, signatureRepo); } @Test void incrementsExistingCounter() { when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); - service.accrue(period, BillingCategory.AI, 5); + service.accrue(period, BillingCategory.AI, 5, null); verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); verify(repo, never()).saveAndFlush(any()); @@ -46,7 +48,7 @@ void incrementsExistingCounter() { void insertsWhenNoRowExists() { when(repo.increment(eq(period), eq("API"), eq(3L), any())).thenReturn(0); - service.accrue(period, BillingCategory.API, 3); + service.accrue(period, BillingCategory.API, 3, null); verify(repo).saveAndFlush(any(UsageCounter.class)); } @@ -58,17 +60,39 @@ void retriesIncrementWhenInsertLosesRace() { 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); + 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); - service.accrue(period, BillingCategory.AI, 0); - service.accrue(null, BillingCategory.AI, 5); + service.accrue(period, BillingCategory.BYPASSED, 5, null); + service.accrue(period, BillingCategory.AI, 0, null); + service.accrue(null, BillingCategory.AI, 5, null); - verifyNoInteractions(repo); + verifyNoInteractions(repo, signatureRepo); + } + + @Test + void claimsNewSignatureThenAccrues() { + 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 skipsAccrualWhenSignatureAlreadyClaimed() { + // Re-submission of the identical input set → signature claim fails → not re-charged. + when(signatureRepo.saveAndFlush(any())) + .thenThrow(new DataIntegrityViolationException("dup")); + + service.accrue(period, BillingCategory.AI, 5, "op-sig-dup"); + + verify(repo, never()).increment(any(), any(), anyLong(), any()); + verify(repo, never()).saveAndFlush(any()); } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java index 86e6e4ab47..4e62afcbd8 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java @@ -1,22 +1,18 @@ package stirling.software.saas.payg.lineage; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; import java.util.Set; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import stirling.software.proprietary.billing.ContentHasher; + /** * SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every - * content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read - * buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions). + * content type, doesn't parse. Delegates to the shared {@link ContentHasher} so the cloud charge + * path and a linked self-hosted instance's meter compute byte-identical signatures. * *

    Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code * PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed @@ -26,36 +22,15 @@ @Profile("saas") public class ByteHashSignatureExtractor implements LineageSignatureExtractor { - private static final String ALGORITHM = "SHA-256"; private static final String SIGNATURE_TYPE = "sha256"; - private static final int BUFFER_SIZE = 64 * 1024; @Override public Set extract(Path file) throws IOException { - MessageDigest digest = newDigest(); - try (InputStream raw = Files.newInputStream(file); - DigestInputStream in = new DigestInputStream(raw, digest)) { - byte[] buf = new byte[BUFFER_SIZE]; - // Drain through the digest stream; we only care about side effects on the digest. - while (in.read(buf) != -1) { - // no-op - } - } - String hex = HexFormat.of().formatHex(digest.digest()); - return Set.of(new LineageSignature(SIGNATURE_TYPE, hex)); + return Set.of(new LineageSignature(SIGNATURE_TYPE, ContentHasher.sha256(file))); } @Override public String name() { return SIGNATURE_TYPE; } - - private static MessageDigest newDigest() { - try { - return MessageDigest.getInstance(ALGORITHM); - } catch (NoSuchAlgorithmException e) { - // SHA-256 is mandated by every JDK; unreachable in practice. - throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e); - } - } } From 90b6476905794767c3fbaa639dcea3a357e091ce Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Tue, 30 Jun 2026 17:06:31 +0100 Subject: [PATCH 13/20] feat(billing): drop synced/unsynced UI split, add manual sync trigger + metering debug logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Portal: remove the "+N processed locally, pending sync" note — the customer doesn't need the synced-vs-pending distinction; the card still shows the combined current-usage total. Dropped the now-unused i18n keys. - New admin endpoint POST /api/v1/account-link/sync-now (.local, ObjectProvider- gated) forces an immediate usage sync — an ops "reconcile now" action + test aid so you don't wait on the scheduler. 204 on run, 409 when metering is off. - Debug logging on the metering path (interceptor + dedup claim) so a single re-run shows category / multipart / hashed-files / op-signature / dedup hit-or-miss — to diagnose why an op did or didn't dedup. --- .../accountlink/AccountLinkController.java | 28 +++++++++++++++-- .../InstanceEntitlementInterceptor.java | 21 ++++++++++--- .../accountlink/UsageMeterService.java | 4 +++ .../AccountLinkControllerTest.java | 31 ++++++++++++++++++- .../public/locales/en-US/translation.toml | 2 -- .../components/billing/PdfsProcessedCard.tsx | 12 ++----- 6 files changed, 79 insertions(+), 19 deletions(-) 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 2119dc2d09..52af366df4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -2,6 +2,7 @@ import java.io.IOException; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -24,7 +25,8 @@ * 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; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds - * to SaaS-synced spend. + * 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. @@ -40,10 +42,16 @@ 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, LocalUsageService localUsageService) { + 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. */ @@ -97,4 +105,20 @@ public ResponseEntity unlink() { 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/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 030741584f..2759b15f15 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 @@ -163,11 +163,15 @@ private void meterRequest( MultipartHttpServletRequest mreq = WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); if (mreq == null) { - meter.accrue( - ent.periodStart(), + long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy); + log.debug( + "Account-link meter: {} {} category={} fileless (no multipart) units={} →" + + " no dedup", + request.getMethod(), + request.getRequestURI(), category, - DocumentUnitCalculator.unitsForFile(0, 0, policy), - null); + fileless); + meter.accrue(ent.periodStart(), category, fileless, null); return; } List temps = new ArrayList<>(); @@ -207,6 +211,15 @@ private void meterRequest( // 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; + log.debug( + "Account-link meter: {} {} category={} files={} hashed={} units={} dedupSig={}", + request.getMethod(), + request.getRequestURI(), + category, + fileCount, + hashes.size(), + units, + opSignature == null ? "none(no-dedup)" : opSignature); meter.accrue(ent.periodStart(), category, units, opSignature); } finally { for (TempFile temp : temps) { 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 index 2098df7de2..7a595558f0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java @@ -70,8 +70,12 @@ private boolean claimSignature(LocalDateTime periodStart, String opSignature) { try { signatureRepo.saveAndFlush( new MeteredInputSignature(periodStart, opSignature, LocalDateTime.now())); + log.debug("Account-link dedup: claimed new signature {}", opSignature); return true; } catch (DataIntegrityViolationException alreadyMetered) { + log.debug( + "Account-link dedup: signature {} already metered this period — skipping charge", + opSignature); return false; // same input set already billed this period } catch (RuntimeException e) { // Never let a dedup-store hiccup drop a charge; treat as "not a duplicate" and accrue. 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 405869f11c..41f544de7e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -2,12 +2,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -21,12 +24,18 @@ class AccountLinkControllerTest { private AccountLinkService service; + private UsageSyncService syncService; + private ObjectProvider syncProvider; private AccountLinkController controller; @BeforeEach + @SuppressWarnings("unchecked") void setUp() { service = mock(AccountLinkService.class); - controller = new AccountLinkController(service, mock(LocalUsageService.class)); + syncService = mock(UsageSyncService.class); + syncProvider = mock(ObjectProvider.class); + controller = + new AccountLinkController(service, mock(LocalUsageService.class), syncProvider); } @Test @@ -65,4 +74,24 @@ void link_transportFailure_maps502() throws Exception { ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); } + + @Test + void syncNow_triggersSyncWhenMeteringOn() { + when(syncProvider.getIfAvailable()).thenReturn(syncService); + + ResponseEntity resp = controller.syncNow(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(syncService).syncNow(); + } + + @Test + void syncNow_returns409WhenMeteringOff() { + when(syncProvider.getIfAvailable()).thenReturn(null); // metering disabled → bean absent + + ResponseEntity resp = controller.syncNow(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + verify(syncService, never()).syncNow(); + } } diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index 1601290b94..c0b3ccae91 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -1742,8 +1742,6 @@ segmentAutomationDesc = "Automations & pipelines" legendValue_one = "{{formatted}} PDFs" legendValue_other = "{{formatted}} PDFs" emptyPeriod = "No metered processing yet this period." -pendingSync_one = "+{{formatted}} processed locally, pending sync" -pendingSync_other = "+{{formatted}} processed locally, pending sync" [billing.spendThisMonth] eyebrow = "Spend this month" diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx index 3bbfd815d6..144d52347d 100644 --- a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx +++ b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx @@ -12,7 +12,8 @@ import type { LocalUsage } from "@portal/api/link"; * *

    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, with a "pending sync" note for honesty. + * 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; @@ -74,15 +75,6 @@ export function PdfsProcessedCard({ - {pending > 0 && ( -

    - {t("billing.pdfsProcessed.pendingSync", { - count: pending, - formatted: pending.toLocaleString(), - })} -

    - )} - {total > 0 ? ( <>
    Date: Tue, 30 Jun 2026 17:23:39 +0100 Subject: [PATCH 14/20] fix(billing): allow LINKED_INSTANCE in payg_shadow_charge.job_source check (V26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instance daily-sync charge writes a payg_shadow_charge row with job_source=LINKED_INSTANCE via chargeStandalone, but that table's job_source CHECK constraint (added Supabase-side; the main-repo V16 added the column with no check) predates the value → the insert failed the check and 500'd POST /api/v1/instance/sync. - V26 widens the constraint to include LINKED_INSTANCE (idempotent DROP IF EXISTS + ADD, additive superset of the JobSource enum). Supabase twin owed in the SaaS repo. - Drop the unused ReferenceType.INSTANCE_SYNC — the ledger writes ReferenceType.JOB, so it was dead and would be the same constraint landmine if ever written. --- .../saas/payg/model/ReferenceType.java | 4 +--- ...g_shadow_charge_linked_instance_source.sql | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java b/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java index 9ea42d9965..d966b3d22e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/ReferenceType.java @@ -5,7 +5,5 @@ public enum ReferenceType { JOB, INVOICE, STRIPE_EVENT, - ADMIN, - /** A linked self-hosted instance's daily usage sync (combined-billing "Mode A"). */ - INSTANCE_SYNC + ADMIN } 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')); From 0d8ff76afe25eae05c7c41f5aa08854fbad0a26f Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Thu, 2 Jul 2026 10:29:52 +0100 Subject: [PATCH 15/20] feat(billing): instant subscription reflection + real-time free-grant gate (account-link Mode A) Tighten the combined-billing "Mode A" loop so a linked instance reflects billing changes promptly instead of lagging cache TTLs, and enforce the free grant locally in real time. Instance (proprietary): - Gate depletes the free grant by locally-accrued unsynced usage, blocking AT the grant in real time instead of overshooting until the next sync charges the backlog (InstanceEntitlementGate + tests). - Idle manual sync now forces an entitlement refresh so a just-subscribed instance unblocks immediately rather than waiting out the ~5-min cache TTL (UsageSyncService). - Strip verbose metering debug logs; keep the error-path logs. SaaS: - POST /instance/sync and GET /instance/entitlement invalidate the team snapshot so the instance sees fresh subscription/spend at once; new POST /payg/wallet/refresh lets the portal drop its own cache post-checkout (InstanceController, PaygWalletController + tests). Portal: - Fold instance-local unsynced usage into the free trial meter (WalletMeter). - Checkout modal stays open through activation (finalize + poll), matches the SaaS plan modal width so Stripe renders its desktop layout, and requests redirect_on_completion:"never" so onComplete fires in-page (no reload); nudges the local instance to refresh on completion. --- .../accountlink/InstanceEntitlementGate.java | 35 ++++- .../InstanceEntitlementInterceptor.java | 16 -- .../accountlink/UsageMeterService.java | 4 - .../accountlink/UsageSyncService.java | 9 +- .../InstanceEntitlementGateTest.java | 63 ++++++-- .../InstanceEntitlementGateWiringTest.java | 12 +- .../accountlink/UsageSyncServiceTest.java | 7 +- .../saas/accountlink/InstanceController.java | 13 ++ .../saas/payg/api/PaygWalletController.java | 27 ++++ .../accountlink/InstanceControllerTest.java | 6 + .../payg/api/PaygWalletControllerTest.java | 46 ++++++ .../public/locales/en-US/translation.toml | 10 ++ frontend/portal/src/api/billing.ts | 12 ++ frontend/portal/src/api/link.ts | 11 ++ frontend/portal/src/billing/stripe.ts | 5 + .../src/components/billing/FreePlanView.tsx | 20 ++- .../billing/StripeCheckoutModal.tsx | 140 +++++++++++++----- .../src/components/billing/WalletMeter.tsx | 21 ++- .../portal/src/components/billing/billing.css | 41 +++++ frontend/portal/src/mocks/handlers/link.ts | 8 + frontend/portal/src/views/Usage.tsx | 55 +++---- 21 files changed, 448 insertions(+), 113 deletions(-) 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 66c6590c16..a7887a370c 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 @@ -37,16 +37,19 @@ public class InstanceEntitlementGate { private final DeviceCredentialStore credentialStore; private final EntitlementCache entitlementCache; private final AccountLinkSyncStateRepository syncStateRepository; + private final LocalUsageService localUsageService; public InstanceEntitlementGate( AccountLinkProperties properties, DeviceCredentialStore credentialStore, EntitlementCache entitlementCache, - AccountLinkSyncStateRepository syncStateRepository) { + 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. */ @@ -61,20 +64,35 @@ public GateDecision evaluate(boolean billable) { Optional entitlement = linked ? entitlementCache.current() : Optional.empty(); boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired(); - return decide(true, true, linked, entitlement, graceExpired); + // For an unsubscribed team the one-time free grant is the ceiling, and it depletes in real + // time as billable work accrues locally between daily syncs. Subtract the not-yet-synced + // local usage from the last-synced free balance so the gate stops AT the grant, rather than + // running until the next sync charges the backlog and only then flips. Subscribed teams + // bill + // past the grant (their gate is the money cap), so this doesn't apply to them — pass 0. + long pendingUnsynced = + entitlement.map(e -> !e.subscribed()).orElse(false) + ? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits() + : 0L; + return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced); } /** * Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown" * (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 — subtracted + * from an unsubscribed team's free balance so the grant depletes in real time (0 for + * subscribed / unknown-entitlement cases, where it has no effect). */ public static GateDecision decide( boolean flagEnabled, boolean billable, boolean linked, Optional entitlement, - boolean graceExpired) { + boolean graceExpired, + long pendingUnsyncedUnits) { if (!flagEnabled) { return GateDecision.allow(GateDecision.Reason.FLAG_OFF); } @@ -98,7 +116,7 @@ public static GateDecision decide( // 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); } @@ -135,7 +153,7 @@ private LocalDateTime lastAuthoritativeContact() { } /** 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; } @@ -143,7 +161,10 @@ private static boolean entitled(InstanceEntitlement e) { // Subscribed: allowed unless a period cap is set and exceeded. return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits(); } - // Unsubscribed: only the free pool covers billable work. - return e.freeRemainingUnits() > 0; + // Unsubscribed: the free pool must cover both what SaaS has already charged (already netted + // out of freeRemainingUnits) and the local usage accrued since the last sync but not yet + // reported. Depleting by the pending delta stops the (grant+1)-th unit here in real time, + // instead of allowing it until a sync reconciles and the gate belatedly flips. + 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 2759b15f15..4ae5c63085 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 @@ -164,13 +164,6 @@ private void meterRequest( WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); if (mreq == null) { long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy); - log.debug( - "Account-link meter: {} {} category={} fileless (no multipart) units={} →" - + " no dedup", - request.getMethod(), - request.getRequestURI(), - category, - fileless); meter.accrue(ent.periodStart(), category, fileless, null); return; } @@ -211,15 +204,6 @@ private void meterRequest( // 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; - log.debug( - "Account-link meter: {} {} category={} files={} hashed={} units={} dedupSig={}", - request.getMethod(), - request.getRequestURI(), - category, - fileCount, - hashes.size(), - units, - opSignature == null ? "none(no-dedup)" : opSignature); meter.accrue(ent.periodStart(), category, units, opSignature); } finally { for (TempFile temp : temps) { 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 index 7a595558f0..2098df7de2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java @@ -70,12 +70,8 @@ private boolean claimSignature(LocalDateTime periodStart, String opSignature) { try { signatureRepo.saveAndFlush( new MeteredInputSignature(periodStart, opSignature, LocalDateTime.now())); - log.debug("Account-link dedup: claimed new signature {}", opSignature); return true; } catch (DataIntegrityViolationException alreadyMetered) { - log.debug( - "Account-link dedup: signature {} already metered this period — skipping charge", - opSignature); return false; // same input set already billed this period } catch (RuntimeException e) { // Never let a dedup-store hiccup drop a charge; treat as "not a duplicate" and accrue. 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 index e51250601d..7162444d70 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -94,7 +94,14 @@ public void syncNow() { } List periods = counters.findPeriodsWithUnsyncedUsage(); if (periods.isEmpty()) { - return; // nothing accrued since the last successful sync + // Nothing accrued to report — but a sync is also our cue to pick up an out-of-band + // entitlement change (e.g. the admin just subscribed), which otherwise wouldn't surface + // until the entitlement-cache TTL lapses. Force an immediate refresh so the gate + // reflects the new plan now. invalidate() marks the snapshot stale; current() then does + // the blocking re-fetch and re-populates the cache for the next billable request. + entitlementCache.invalidate(); + entitlementCache.current(); + return; } InstanceEntitlement latest = null; try { 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 1becab0f02..f2be6f84cb 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 @@ -27,6 +27,7 @@ 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); @@ -47,7 +48,7 @@ private static InstanceEntitlement subscribedOverCap() { @Test void flagOff_allowsEverything_evenBillableUnlinked() { GateDecision d = - InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false); + InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FLAG_OFF, d.reason()); } @@ -55,28 +56,31 @@ void flagOff_allowsEverything_evenBillableUnlinked() { @Test void manualTool_alwaysFree_evenUnlinked() { GateDecision d = - InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false); + 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(), false); + 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_withinGrace_failsOpen() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false); + 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); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L); assertFalse(d.allowed()); assertEquals(Reason.GRACE_EXPIRED, d.reason()); } @@ -84,7 +88,26 @@ void billable_linked_entitlementUnreachable_graceExpired_blocks() { @Test void billable_linked_freePoolAvailable_allows() { GateDecision d = - InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false); + 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()); } @@ -93,7 +116,7 @@ void billable_linked_freePoolAvailable_allows() { void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(exhaustedUnsubscribed()), false); + true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -102,7 +125,7 @@ void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { void billable_linked_subscribedWithinCap_allows() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(subscribedWithinCap()), false); + true, true, true, Optional.of(subscribedWithinCap()), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -111,7 +134,7 @@ void billable_linked_subscribedWithinCap_allows() { void billable_linked_subscribedOverCap_blocks() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(subscribedOverCap()), false); + true, true, true, Optional.of(subscribedOverCap()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -123,7 +146,7 @@ void billable_linked_revoked_blocksWithRevokedSignal() { InstanceEntitlement revoked = new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED); GateDecision d = - InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false); + InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.REVOKED, d.reason()); } @@ -134,7 +157,8 @@ void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() { InstanceEntitlement conflicting = new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT); GateDecision d = - InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting), false); + InstanceEntitlementGate.decide( + true, true, true, Optional.of(conflicting), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -143,7 +167,7 @@ void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() { private InstanceEntitlementGate gate(AccountLinkProperties props) { return new InstanceEntitlementGate( - props, credentialStore, entitlementCache, syncStateRepository); + props, credentialStore, entitlementCache, syncStateRepository, localUsageService); } private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) { @@ -196,4 +220,19 @@ void evaluate_recentSync_withinGrace_failsOpen() { 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()); + } } 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 f3dca44266..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,9 +28,14 @@ void setUp() { properties.setEnabled(true); store = mock(DeviceCredentialStore.class); cache = mock(EntitlementCache.class); + localUsage = mock(LocalUsageService.class); gate = new InstanceEntitlementGate( - properties, store, cache, mock(AccountLinkSyncStateRepository.class)); + properties, + store, + cache, + mock(AccountLinkSyncStateRepository.class), + localUsage); } @Test @@ -57,6 +63,10 @@ void billableLinkedConsultsCache() { .thenReturn( Optional.of( new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK))); + // Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time; + // nothing pending here, so the 5 free units still allow the request. + when(localUsage.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0)); GateDecision d = gate.evaluate(true); assertTrue(d.allowed()); assertEquals(GateDecision.Reason.ENTITLED, d.reason()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java index e2c94d31e0..1b1ade7e80 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java @@ -89,15 +89,20 @@ void notLinkedSkipsEntirely() { } @Test - void nothingPendingDoesNotReport() { + 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 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 dba36d8313..d0e738ed22 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 @@ -127,6 +127,13 @@ public ResponseEntity entitlement(Authentication auth) { if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + // Drop any cached snapshot before building. The instance polls this only every few minutes + // and gates real-time billable work on the answer, so it must reflect subscription / cap + // changes (e.g. a just-completed checkout) immediately, not up to a cache-TTL later. The + // subscription flip is written by a Postgres function (payg_link_subscription) with no Java + // event to invalidate on, so this low-frequency instance-facing read is where we guarantee + // freshness — the SaaS caches still shield the high-frequency cloud guard path. + entitlementService.invalidate(token.getTeamId()); return ResponseEntity.ok(buildEntitlement(token.getTeamId())); } @@ -188,6 +195,12 @@ public ResponseEntity sync( BillingCategory.API, c.api(), BillingCategory.AI, c.ai(), BillingCategory.AUTOMATION, c.automation())); + // A sync lands the whole delta at once — the admin is typically watching the usage page + // right after — so drop the 30s snapshot/billing cache now instead of letting the daily + // charge sit invisible until the TTL lapses. Refreshes both the period spend and the + // free-grant balance the ingest just moved. The buildEntitlement below (and the portal's + // next wallet read) then reflect the charge immediately. + entitlementService.invalidate(teamId); return ResponseEntity.ok(buildEntitlement(teamId)); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index 041eaa8f4a..b79d57117e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -328,6 +329,32 @@ public ResponseEntity updateCap( /** Request body for {@link #updateCap}. */ public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {} + // --------------------------------------------------------------------------------------- + // POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh + // --------------------------------------------------------------------------------------- + + /** + * Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a + * billing state that just changed out-of-band. The subscription flip is written by a Postgres + * function ({@code payg_link_subscription}) with no Java event to invalidate on, so a client + * that knows a change just happened — the portal while finalizing a checkout — pokes the cache + * here rather than waiting out the ~30s TTL. Team-scoped to the caller: a client can only + * refresh its own team, and a no-team caller is a cheap no-op. + */ + @PostMapping("/wallet/refresh") + @PreAuthorize("isAuthenticated()") + public ResponseEntity refreshWallet(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + primaryMembership(user.getId()) + .ifPresent(m -> entitlementService.invalidate(m.getTeam().getId())); + return ResponseEntity.noContent().build(); + } + // --------------------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------------------- diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java index b7e3f5e2da..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 @@ -89,6 +89,9 @@ void entitlement_resolvesTeamFromTokenAndMapsSnapshot() { 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 @@ -168,6 +171,9 @@ void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() { .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 diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java index 8433f5e5a3..6cc97d916b 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -432,6 +432,52 @@ void updateCap_anonymousIs401() { verifyNoInteractions(policyRepo, entitlementService); } + // ----------------------------------------------------------------------------------------- + // POST /wallet/refresh + // ----------------------------------------------------------------------------------------- + + @Test + void refreshWallet_dropsCallerTeamCache() { + User user = userWithId(30L, UUID.randomUUID()); + Team team = teamWithId(70L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(30L)) + .thenReturn(List.of(membership(team, user, TeamRole.MEMBER))); + + ResponseEntity resp = controller.refreshWallet(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + // Portal pokes this after checkout so the next /wallet read reflects the subscription + // immediately rather than after the cache TTL. + verify(entitlementService).invalidate(70L); + } + + @Test + void refreshWallet_noTeam_isNoOpButOk() { + User user = userWithId(31L, UUID.randomUUID()); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(31L)).thenReturn(List.of()); + + ResponseEntity resp = controller.refreshWallet(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(entitlementService, never()).invalidate(any()); + } + + @Test + void refreshWallet_anonymousIs401() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller.refreshWallet(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(entitlementService); + } + // ----------------------------------------------------------------------------------------- // Fixtures // ----------------------------------------------------------------------------------------- diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index c0b3ccae91..729600aa47 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -1822,6 +1822,16 @@ bodyAfter = "in the portal env to enable in-app checkout." [billing.checkout.error] title = "Couldn't start checkout" +[billing.checkout.finalizing] +title = "Activating your Processor plan…" +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." + +[billing.checkout.activationSlow] +title = "Almost there" +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" + [billing.subscribedPlan.capWarn] reachedTitle = "Monthly spend limit reached" approachingTitle = "You're at {{pct}}% of your monthly spend limit" diff --git a/frontend/portal/src/api/billing.ts b/frontend/portal/src/api/billing.ts index 7c87bb5ca5..92b3a66b2f 100644 --- a/frontend/portal/src/api/billing.ts +++ b/frontend/portal/src/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/portal/src/api/link.ts b/frontend/portal/src/api/link.ts index bb6f83b6a8..9b4876ac8d 100644 --- a/frontend/portal/src/api/link.ts +++ b/frontend/portal/src/api/link.ts @@ -77,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/portal/src/billing/stripe.ts b/frontend/portal/src/billing/stripe.ts index 068046ef37..6c49e49062 100644 --- a/frontend/portal/src/billing/stripe.ts +++ b/frontend/portal/src/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/portal/src/components/billing/FreePlanView.tsx b/frontend/portal/src/components/billing/FreePlanView.tsx index 2d8526d805..b45ad2cd38 100644 --- a/frontend/portal/src/components/billing/FreePlanView.tsx +++ b/frontend/portal/src/components/billing/FreePlanView.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, StatusBadge } from "@shared/components"; 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); @@ -78,7 +85,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { {/* Processor trial — meter with the inline upgrade CTA */} - + {missingTeam && ( @@ -100,10 +107,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { onClose={() => setModalOpen(false)} teamId={wallet.teamId} currency={currency} - onComplete={() => { - setModalOpen(false); - onSubscribed?.(); - }} + onComplete={() => onSubscribed?.() ?? Promise.resolve(false)} /> )}
    diff --git a/frontend/portal/src/components/billing/StripeCheckoutModal.tsx b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx index ac590adc71..c0df95bd75 100644 --- a/frontend/portal/src/components/billing/StripeCheckoutModal.tsx +++ b/frontend/portal/src/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 "@shared/components"; +import { Banner, Button, Modal, Skeleton, Spinner } from "@shared/components"; 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; } /** @@ -57,16 +61,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; @@ -110,42 +127,99 @@ 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("billing.checkout.notConfigured.bodyBefore")}{" "} - VITE_STRIPE_PUBLISHABLE_KEY{" "} - {t("billing.checkout.notConfigured.bodyAfter")} - - )} - {publishableKey && error && ( - - {error} - + {phase === "finalizing" && ( +
    + +

    + {t("billing.checkout.finalizing.title")} +

    +

    + {t("billing.checkout.finalizing.body")} +

    +

    + {t("billing.checkout.finalizing.hint")} +

    +
    )} - {publishableKey && loading && !error && ( -
    - - + + {phase === "activationSlow" && ( +
    +

    + {t("billing.checkout.activationSlow.title")} +

    +

    + {t("billing.checkout.activationSlow.body")} +

    +
    )} - {publishableKey && canRender && stripe && clientSecret && ( - - - + + {phase === "checkout" && ( + <> + {!publishableKey && ( + + {t("billing.checkout.notConfigured.bodyBefore")}{" "} + VITE_STRIPE_PUBLISHABLE_KEY{" "} + {t("billing.checkout.notConfigured.bodyAfter")} + + )} + {publishableKey && error && ( + + {error} + + )} + {publishableKey && loading && !error && ( +
    + + +
    + )} + {publishableKey && canRender && stripe && clientSecret && ( + + + + )} + )} ); diff --git a/frontend/portal/src/components/billing/WalletMeter.tsx b/frontend/portal/src/components/billing/WalletMeter.tsx index 506faffb8b..28fefa429b 100644 --- a/frontend/portal/src/components/billing/WalletMeter.tsx +++ b/frontend/portal/src/components/billing/WalletMeter.tsx @@ -3,10 +3,13 @@ import { useTranslation } from "react-i18next"; import { Card } from "@shared/components"; import { formatMinor, MeterBar, meterState } from "@shared/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 @@ -54,14 +65,14 @@ export function WalletMeter({ wallet, action }: Props) {

    diff --git a/frontend/portal/src/components/billing/billing.css b/frontend/portal/src/components/billing/billing.css index ee1f593833..63ea82d748 100644 --- a/frontend/portal/src/components/billing/billing.css +++ b/frontend/portal/src/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/portal/src/mocks/handlers/link.ts b/frontend/portal/src/mocks/handlers/link.ts index 8b604eb3da..6ac6781f71 100644 --- a/frontend/portal/src/mocks/handlers/link.ts +++ b/frontend/portal/src/mocks/handlers/link.ts @@ -50,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/portal/src/views/Usage.tsx b/frontend/portal/src/views/Usage.tsx index 8b358d01b5..9ac5b03ed0 100644 --- a/frontend/portal/src/views/Usage.tsx +++ b/frontend/portal/src/views/Usage.tsx @@ -3,8 +3,12 @@ import { useTranslation } from "react-i18next"; import { Banner, Button, Skeleton } from "@shared/components"; import { useLink } from "@portal/contexts/LinkContext"; import { useUI } from "@portal/contexts/UIContext"; -import { fetchWallet, type Wallet } from "@portal/api/billing"; -import { fetchLocalUsage, type LocalUsage } from "@portal/api/link"; +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"; @@ -44,9 +48,6 @@ export function Usage() { // 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); @@ -122,31 +123,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 ( @@ -180,12 +187,6 @@ export function Usage() { )} - {isLinked && finalizing && ( - - {t("usage.finalizing.body")} - - )} - {isLinked && needsReauth && ( )} - {isLinked && !finalizing && wallet && wallet.status === "free" && ( - + {isLinked && wallet && wallet.status === "free" && ( + )} {isLinked && wallet && wallet.status === "subscribed" && ( From 7199526985807bf49caf51ccc91ed526dfec78c4 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Thu, 2 Jul 2026 12:15:21 +0100 Subject: [PATCH 16/20] style(portal): prettier-format merge-resolved billing files The manual conflict resolution left 3 files (FreePlanView, StripeCheckoutModal, Usage) with formatting Prettier disagreed with, failing frontend-validation's frontend:format:check. Pure formatting; no behaviour change. tsc + eslint clean. --- frontend/portal/src/components/billing/FreePlanView.tsx | 6 +++++- .../portal/src/components/billing/StripeCheckoutModal.tsx | 5 ++++- frontend/portal/src/views/Usage.tsx | 6 +++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/portal/src/components/billing/FreePlanView.tsx b/frontend/portal/src/components/billing/FreePlanView.tsx index 4343c65332..1e4f97f38a 100644 --- a/frontend/portal/src/components/billing/FreePlanView.tsx +++ b/frontend/portal/src/components/billing/FreePlanView.tsx @@ -90,7 +90,11 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { {/* Processor trial — meter with the inline upgrade CTA */} - + {missingTeam && ( {error} diff --git a/frontend/portal/src/views/Usage.tsx b/frontend/portal/src/views/Usage.tsx index f1ab197a01..2b1875efa8 100644 --- a/frontend/portal/src/views/Usage.tsx +++ b/frontend/portal/src/views/Usage.tsx @@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next"; import { Banner, Button, Skeleton } from "@shared/components"; import { useLink } from "@portal/contexts/LinkContext"; import { useUI } from "@portal/contexts/UIContext"; -import { fetchWallet, refreshWalletCache, type Wallet } from "@portal/api/billing"; +import { + fetchWallet, + refreshWalletCache, + type Wallet, +} from "@portal/api/billing"; import { fetchLocalUsage, triggerLocalSync, From 26d316843a1fe98d7f5e68b5034d2348cad55cd8 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Thu, 2 Jul 2026 12:38:23 +0100 Subject: [PATCH 17/20] fix(portal): drop orphaned usage.finalizing i18n keys Removing the old "finalizing" banner from Usage.tsx (the checkout modal owns that state now) left usage.finalizing.title/body unused, which fails the unused-translation guard in frontend:test:editor. The used billing.checkout.finalizing keys are untouched. --- frontend/portal/public/locales/en-US/translation.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index a27ea066f0..6c2720d01a 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -1818,10 +1818,6 @@ title = "Usage & billing" subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console." managePayment = "Manage Payment" -[usage.finalizing] -title = "Finalizing your subscription…" -body = "It can take a few seconds for your subscription to activate. This page updates automatically." - [usage.sessionExpired] title = "Session expired" action = "Sign in again" From f5603fb9df94090ae8d702d659d98f40948f7b98 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Fri, 3 Jul 2026 11:50:19 +0100 Subject: [PATCH 18/20] style(portal): use ASCII in checkout copy (review: @jbrunton96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the unicode ellipsis (…) and em-dash (—) in the finalizing + activationSlow strings with ... and - , in both the en-US toml and the mirrored fallbacks in StripeCheckoutModal. Keys/behaviour unchanged. --- frontend/portal/public/locales/en-US/translation.toml | 6 +++--- .../portal/src/components/billing/StripeCheckoutModal.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index 6c2720d01a..1e4fbbbfcb 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -1793,13 +1793,13 @@ bodyAfter = "in the portal env to enable in-app checkout." title = "Couldn't start checkout" [billing.checkout.finalizing] -title = "Activating your Processor plan…" -body = "Your payment went through. We're switching on metered processing across your linked instances — this usually takes a few seconds." +title = "Activating your Processor plan..." +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." [billing.checkout.activationSlow] title = "Almost there" -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." +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" [billing.subscribedPlan.capWarn] diff --git a/frontend/portal/src/components/billing/StripeCheckoutModal.tsx b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx index c14816e07f..d52bf784d1 100644 --- a/frontend/portal/src/components/billing/StripeCheckoutModal.tsx +++ b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx @@ -173,13 +173,13 @@ export function StripeCheckoutModal({

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

    {t( "billing.checkout.finalizing.body", - "Your payment went through. We're switching on metered processing across your linked instances — this usually takes a few seconds.", + "Your payment went through. We're switching on metered processing across your linked instances - this usually takes a few seconds.", )}

    @@ -199,7 +199,7 @@ export function StripeCheckoutModal({

    {t( "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.", + "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, @@ -167,45 +216,10 @@ export function StripeCheckoutModal({ "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest.", )} > - {phase === "finalizing" && ( -
    - -

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

    -
    - )} + {phase === "finalizing" && } {phase === "activationSlow" && ( -
    -

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

    - -
    + )} {phase === "checkout" && (