From d12be8ea53eb48519263a411dbd7ec7f85da3d80 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 1 Sep 2026 15:26:51 +0100 Subject: [PATCH] Add metering for Automate --- .../AccountLinkAutomationRunBiller.java | 63 +++++++++ .../automation/AutomationMeterController.java | 126 ++++++++++++++++++ .../automation/AutomationRunBiller.java | 23 ++++ .../AccountLinkAutomationRunBillerTest.java | 116 ++++++++++++++++ .../AutomationMeterControllerTest.java | 121 +++++++++++++++++ .../payg/charge/SaasAutomationRunBiller.java | 71 ++++++++++ .../charge/SaasAutomationRunBillerTest.java | 119 +++++++++++++++++ .../tools/automate/useAutomateOperation.ts | 35 ++++- .../src/core/services/automationMeter.ts | 18 +++ .../proprietary/services/automationMeter.ts | 17 +++ 10 files changed, 707 insertions(+), 2 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/automation/AccountLinkAutomationRunBiller.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationMeterController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationRunBiller.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/automation/AccountLinkAutomationRunBillerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/automation/AutomationMeterControllerTest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/charge/SaasAutomationRunBiller.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/charge/SaasAutomationRunBillerTest.java create mode 100644 frontend/editor/src/core/services/automationMeter.ts create mode 100644 frontend/editor/src/proprietary/services/automationMeter.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/automation/AccountLinkAutomationRunBiller.java b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AccountLinkAutomationRunBiller.java new file mode 100644 index 0000000000..b6aa6d8284 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AccountLinkAutomationRunBiller.java @@ -0,0 +1,63 @@ +package stirling.software.proprietary.automation; + +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.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.accountlink.EntitlementCache; +import stirling.software.proprietary.accountlink.InstanceEntitlement; +import stirling.software.proprietary.accountlink.UsageMeterService; +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; + +/** + * Charges a client-side Automate run on a linked self-hosted instance: computes the input set's + * doc-units with the instance's synced {@link UnitCalcPolicy} and accrues them as {@link + * BillingCategory#AUTOMATION}, exactly as the {@code InstanceEntitlementInterceptor} meters a + * policy's tool sub-steps. Metering is instance-scoped, so no per-user context is needed. + * + *

Metering itself is optional (the {@link UsageMeterService} bean is absent when {@code + * metering.enabled=false}); a null signature is passed so each run is its own charge (the + * standalone semantic - no workflow-window dedup). + */ +@Slf4j +@Component +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class AccountLinkAutomationRunBiller implements AutomationRunBiller { + + private final EntitlementCache entitlementCache; + private final ObjectProvider meterProvider; + + public AccountLinkAutomationRunBiller( + EntitlementCache entitlementCache, ObjectProvider meterProvider) { + this.entitlementCache = entitlementCache; + this.meterProvider = meterProvider; + } + + @Override + public void recordAutomationRun(List inputs) { + if (inputs.isEmpty()) { + return; + } + UsageMeterService meter = meterProvider.getIfAvailable(); + if (meter == null) { + return; // metering switch off + } + 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; + } + UnitCalcPolicy policy = ent.unitCalcPolicy(); + long units = DocumentUnitCalculator.unitsForGroup(inputs, policy); + meter.accrue(ent.periodStart(), BillingCategory.AUTOMATION, units, null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationMeterController.java b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationMeterController.java new file mode 100644 index 0000000000..e60b58128f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationMeterController.java @@ -0,0 +1,126 @@ +package stirling.software.proprietary.automation; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.ResponseEntity; +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; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.audit.AuditContext; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; + +/** + * Meters + audits a client-side Automate run so a browser-run workflow bills like the equivalent + * server-side policy. Side-effect only; does no processing itself. The frontend dispatches this + * once, after the run completes. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/automate") +public class AutomationMeterController { + + /** Cap on input documents accepted per call, guarding against a hostile client payload. */ + private static final int MAX_INPUTS = 10_000; + + /** Cap on operations recorded for the audit label. */ + private static final int MAX_OPERATIONS = 1_000; + + private final ObjectProvider biller; + + public AutomationMeterController(ObjectProvider biller) { + this.biller = biller; + } + + @PostMapping("/meter") + @Operation( + summary = "Meter a client-side Automate run", + description = + "Records billing + audit for an automation performed in the browser. Does no" + + " processing itself. Dispatched by the frontend, not for direct use.") + public ResponseEntity meterAutomationRun( + @RequestBody(required = false) AutomationMeterRequest body, + HttpServletRequest request) { + List inputs = sanitizeInputs(body); + if (inputs.isEmpty()) { + // No billable input set - nothing to charge (an empty run still returns 202). + return ResponseEntity.accepted().build(); + } + + String automationName = + body != null && body.automationName() != null && !body.automationName().isBlank() + ? body.automationName() + : "Automation"; + List steps = sanitizeOperations(body); + + // Stamp the run so the audit trail records it as an automation, like a server-run policy. + request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, automationName); + request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, steps); + + AutomationRunBiller runBiller = biller.getIfAvailable(); + if (runBiller != null) { + try { + runBiller.recordAutomationRun(inputs); + } catch (RuntimeException e) { + log.warn( + "[automate meter] billing failed; the run already completed unbilled: {}", + e.getMessage()); + } + } + return ResponseEntity.accepted().build(); + } + + /** Clamp to a sane count and drop malformed entries; negatives are treated as zero. */ + private static List sanitizeInputs(AutomationMeterRequest body) { + if (body == null || body.inputs() == null) { + return List.of(); + } + List out = new ArrayList<>(); + for (InputDoc doc : body.inputs()) { + if (doc == null) { + continue; + } + int pages = doc.pages() != null ? Math.max(0, doc.pages()) : 0; + long bytes = doc.bytes() != null ? Math.max(0L, doc.bytes()) : 0L; + out.add(new FileSize(pages, bytes)); + if (out.size() >= MAX_INPUTS) { + break; + } + } + return out; + } + + private static List sanitizeOperations(AutomationMeterRequest body) { + if (body == null || body.operations() == null) { + return List.of(); + } + List out = new ArrayList<>(); + for (String op : body.operations()) { + if (op != null && !op.isBlank()) { + out.add(op.trim()); + } + if (out.size() >= MAX_OPERATIONS) { + break; + } + } + return out; + } + + /** Frontend payload: the run's name, its operation ids, and per-input page/byte facts. */ + public record AutomationMeterRequest( + String automationName, List operations, List inputs) {} + + /** One input document's page count (0 for non-PDF / unknown) and byte size. */ + public record InputDoc(Integer pages, Long bytes) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationRunBiller.java b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationRunBiller.java new file mode 100644 index 0000000000..4258dd23eb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/automation/AutomationRunBiller.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.automation; + +import java.util.List; + +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; + +/** + * Meters one client-side Automate run. The Automate tool runs its steps in the browser (calling + * each tool's normal endpoint), so no automation sub-step reaches the billing interceptors - this + * biller is how that run is charged instead. SaaS and a linked self-hosted instance each provide an + * implementation; other flavors have no bean and the run is recorded for audit but not charged. + * + *

Charged on the input document set's doc-units, once per run, so an Automate workflow costs the + * same as the equivalent server-side policy over the same inputs (see {@link + * stirling.software.proprietary.billing.DocumentUnitCalculator}). + */ +public interface AutomationRunBiller { + + /** + * Charge one Automate run over {@code inputs} (page/byte facts of the original input files). + */ + void recordAutomationRun(List inputs); +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/automation/AccountLinkAutomationRunBillerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/automation/AccountLinkAutomationRunBillerTest.java new file mode 100644 index 0000000000..c874200c15 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/automation/AccountLinkAutomationRunBillerTest.java @@ -0,0 +1,116 @@ +package stirling.software.proprietary.automation; + +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.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.beans.factory.ObjectProvider; + +import stirling.software.proprietary.accountlink.EntitlementCache; +import stirling.software.proprietary.accountlink.EntitlementState; +import stirling.software.proprietary.accountlink.InstanceEntitlement; +import stirling.software.proprietary.accountlink.UsageMeterService; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; + +class AccountLinkAutomationRunBillerTest { + + private static final UnitCalcPolicy POLICY = new UnitCalcPolicy(10, 1_000_000L, 1, 1000); + private static final LocalDateTime PERIOD = LocalDateTime.of(2026, 9, 1, 0, 0); + + @SuppressWarnings("unchecked") + private static ObjectProvider providerOf(UsageMeterService meter) { + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(meter); + return provider; + } + + private static InstanceEntitlement entitlement(UnitCalcPolicy policy, LocalDateTime period) { + return new InstanceEntitlement( + true, 0L, 0L, null, EntitlementState.OK, policy, period, null); + } + + @Test + void accruesComputedUnitsAsAutomation() { + EntitlementCache cache = mock(EntitlementCache.class); + when(cache.current()).thenReturn(Optional.of(entitlement(POLICY, PERIOD))); + UsageMeterService meter = mock(UsageMeterService.class); + AccountLinkAutomationRunBiller biller = + new AccountLinkAutomationRunBiller(cache, providerOf(meter)); + + // 25 pages -> ceil(25/10)=3 page units; 5000 bytes -> 1 byte unit; max = 3. + biller.recordAutomationRun(List.of(new FileSize(25, 5000L))); + + verify(meter).accrue(PERIOD, BillingCategory.AUTOMATION, 3L, null); + } + + @Test + void noAccrueWhenMeterAbsent() { + EntitlementCache cache = mock(EntitlementCache.class); + AccountLinkAutomationRunBiller biller = + new AccountLinkAutomationRunBiller(cache, providerOf(null)); + + biller.recordAutomationRun(List.of(new FileSize(1, 10L))); + + verify(cache, never()).current(); + } + + @Test + void noAccrueWhenEntitlementUnknown() { + EntitlementCache cache = mock(EntitlementCache.class); + when(cache.current()).thenReturn(Optional.empty()); + UsageMeterService meter = mock(UsageMeterService.class); + AccountLinkAutomationRunBiller biller = + new AccountLinkAutomationRunBiller(cache, providerOf(meter)); + + biller.recordAutomationRun(List.of(new FileSize(1, 10L))); + + verify(meter, never()) + .accrue( + ArgumentMatchers.any(), + ArgumentMatchers.any(), + ArgumentMatchers.anyLong(), + ArgumentMatchers.any()); + } + + @Test + void noAccrueWhenPeriodOrPolicyMissing() { + EntitlementCache cache = mock(EntitlementCache.class); + when(cache.current()) + .thenReturn(Optional.of(entitlement(POLICY, null))) + .thenReturn(Optional.of(entitlement(null, PERIOD))); + UsageMeterService meter = mock(UsageMeterService.class); + AccountLinkAutomationRunBiller biller = + new AccountLinkAutomationRunBiller(cache, providerOf(meter)); + + biller.recordAutomationRun(List.of(new FileSize(1, 10L))); + biller.recordAutomationRun(List.of(new FileSize(1, 10L))); + + verify(meter, never()) + .accrue( + ArgumentMatchers.any(), + ArgumentMatchers.any(), + ArgumentMatchers.anyLong(), + ArgumentMatchers.any()); + } + + @Test + void noAccrueForEmptyInputs() { + EntitlementCache cache = mock(EntitlementCache.class); + UsageMeterService meter = mock(UsageMeterService.class); + AccountLinkAutomationRunBiller biller = + new AccountLinkAutomationRunBiller(cache, providerOf(meter)); + + biller.recordAutomationRun(List.of()); + + verify(cache, never()).current(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/automation/AutomationMeterControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/automation/AutomationMeterControllerTest.java new file mode 100644 index 0000000000..5190a613d7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/automation/AutomationMeterControllerTest.java @@ -0,0 +1,121 @@ +package stirling.software.proprietary.automation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +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.util.List; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.proprietary.audit.AuditContext; +import stirling.software.proprietary.automation.AutomationMeterController.AutomationMeterRequest; +import stirling.software.proprietary.automation.AutomationMeterController.InputDoc; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; + +class AutomationMeterControllerTest { + + @SuppressWarnings("unchecked") + private static ObjectProvider providerOf(AutomationRunBiller biller) { + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(biller); + return provider; + } + + private static AutomationMeterRequest req(List inputs) { + return new AutomationMeterRequest("My run", List.of("compress", "rotate"), inputs); + } + + @Test + void billsInputsAndStampsAudit() { + AutomationRunBiller biller = mock(AutomationRunBiller.class); + AutomationMeterController controller = new AutomationMeterController(providerOf(biller)); + MockHttpServletRequest http = new MockHttpServletRequest(); + + var response = + controller.meterAutomationRun( + req(List.of(new InputDoc(3, 1000L), new InputDoc(0, 2048L))), http); + + assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(biller).recordAutomationRun(captor.capture()); + assertEquals(List.of(new FileSize(3, 1000L), new FileSize(0, 2048L)), captor.getValue()); + assertEquals("My run", http.getAttribute(AuditContext.REQ_ATTR_POLICY_NAME)); + assertEquals( + List.of("compress", "rotate"), + http.getAttribute(AuditContext.REQ_ATTR_POLICY_STEPS)); + } + + @Test + void emptyInputsDoesNotBill() { + AutomationRunBiller biller = mock(AutomationRunBiller.class); + AutomationMeterController controller = new AutomationMeterController(providerOf(biller)); + + var response = controller.meterAutomationRun(req(List.of()), new MockHttpServletRequest()); + + assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); + verify(biller, never()).recordAutomationRun(org.mockito.ArgumentMatchers.anyList()); + } + + @Test + void nullBodyIsAccepted() { + AutomationRunBiller biller = mock(AutomationRunBiller.class); + AutomationMeterController controller = new AutomationMeterController(providerOf(biller)); + + var response = controller.meterAutomationRun(null, new MockHttpServletRequest()); + + assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); + verify(biller, never()).recordAutomationRun(org.mockito.ArgumentMatchers.anyList()); + } + + @Test + void noBillerBeanStillAccepts() { + AutomationMeterController controller = new AutomationMeterController(providerOf(null)); + + var response = + controller.meterAutomationRun( + req(List.of(new InputDoc(1, 10L))), new MockHttpServletRequest()); + + assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); + } + + @Test + void billingExceptionIsSwallowed() { + AutomationRunBiller biller = mock(AutomationRunBiller.class); + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(biller) + .recordAutomationRun(org.mockito.ArgumentMatchers.anyList()); + AutomationMeterController controller = new AutomationMeterController(providerOf(biller)); + + var response = + controller.meterAutomationRun( + req(List.of(new InputDoc(1, 10L))), new MockHttpServletRequest()); + + assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); + } + + @Test + void clampsNegativesAndCapsInputCount() { + AutomationRunBiller biller = mock(AutomationRunBiller.class); + AutomationMeterController controller = new AutomationMeterController(providerOf(biller)); + + List many = + IntStream.range(0, 10_050).mapToObj(i -> new InputDoc(-5, -1L)).toList(); + controller.meterAutomationRun( + new AutomationMeterRequest(null, null, many), new MockHttpServletRequest()); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(biller).recordAutomationRun(captor.capture()); + List billed = captor.getValue(); + assertEquals(10_000, billed.size()); + assertTrue(billed.stream().allMatch(f -> f.pages() == 0 && f.bytes() == 0L)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasAutomationRunBiller.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasAutomationRunBiller.java new file mode 100644 index 0000000000..b1d85197c0 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasAutomationRunBiller.java @@ -0,0 +1,71 @@ +package stirling.software.saas.payg.charge; + +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.automation.AutomationRunBiller; +import stirling.software.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.User; +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.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Charges one client-side Automate run as a standalone AUTOMATION job, on the input set's doc-units + * with the team's effective {@link PricingPolicy} - so a browser-run workflow costs the same as the + * equivalent server-side policy over the same inputs. + */ +@Component +@Profile("saas") +@RequiredArgsConstructor +public class SaasAutomationRunBiller implements AutomationRunBiller { + + private final UserRepository userRepository; + private final PricingPolicyService pricingPolicyService; + private final JobChargeService jobChargeService; + + @Override + public void recordAutomationRun(List inputs) { + if (inputs.isEmpty()) { + return; + } + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + User user = AuthenticationUtils.getCurrentUser(auth, userRepository); + if (user == null || user.getTeam() == null) { + return; + } + PricingPolicy policy = pricingPolicyService.getEffectivePolicy(user.getTeam().getId()); + UnitCalcPolicy unitCalc = + new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()); + int units = DocumentUnitCalculator.unitsForGroup(inputs, unitCalc); + + JobSource source = + auth instanceof ApiKeyAuthenticationToken ? JobSource.API : JobSource.WEB; + ChargeContext ctx = + new ChargeContext( + user.getId(), + user.getTeam().getId(), + source, + ProcessType.AUTOMATION, + BillingCategory.AUTOMATION); + // chargeStandalone re-resolves the effective policy and applies its minChargeUnits floor. + jobChargeService.chargeStandalone(ctx, units); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/SaasAutomationRunBillerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/SaasAutomationRunBillerTest.java new file mode 100644 index 0000000000..b6aea045ae --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/SaasAutomationRunBillerTest.java @@ -0,0 +1,119 @@ +package stirling.software.saas.payg.charge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +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.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.User; +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.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; + +class SaasAutomationRunBillerTest { + + private final UserRepository userRepository = mock(UserRepository.class); + private final PricingPolicyService pricingPolicyService = mock(PricingPolicyService.class); + private final JobChargeService jobChargeService = mock(JobChargeService.class); + private final SaasAutomationRunBiller biller = + new SaasAutomationRunBiller(userRepository, pricingPolicyService, jobChargeService); + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private static User userWithTeam(long userId, long teamId) { + Team team = mock(Team.class); + when(team.getId()).thenReturn(teamId); + User user = mock(User.class); + when(user.getId()).thenReturn(userId); + when(user.getTeam()).thenReturn(team); + return user; + } + + private static PricingPolicy policy() { + PricingPolicy policy = mock(PricingPolicy.class); + when(policy.getDocPagesPerUnit()).thenReturn(10); + when(policy.getDocBytesPerUnit()).thenReturn(1_000_000L); + when(policy.getMinChargeUnits()).thenReturn(1); + when(policy.getFileUnitCap()).thenReturn(1000); + return policy; + } + + private static void authenticateAs(Authentication auth, User principal) { + when(auth.getPrincipal()).thenReturn(principal); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + @Test + void chargesComputedUnitsAsWebAutomation() { + User user = userWithTeam(3L, 7L); + authenticateAs(mock(Authentication.class), user); + PricingPolicy policy = policy(); + when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy); + + // 25 pages -> ceil(25/10)=3 page units; 5000 bytes -> 1 byte unit; max = 3. + biller.recordAutomationRun(List.of(new FileSize(25, 5000L))); + + ArgumentCaptor ctx = ArgumentCaptor.forClass(ChargeContext.class); + verify(jobChargeService).chargeStandalone(ctx.capture(), eq(3)); + assertEquals(3L, ctx.getValue().ownerUserId()); + assertEquals(7L, ctx.getValue().ownerTeamId()); + assertEquals(JobSource.WEB, ctx.getValue().source()); + assertEquals(ProcessType.AUTOMATION, ctx.getValue().processType()); + assertEquals(BillingCategory.AUTOMATION, ctx.getValue().billingCategory()); + } + + @Test + void apiKeyAuthChargesAsApiSource() { + User user = userWithTeam(3L, 7L); + authenticateAs(mock(ApiKeyAuthenticationToken.class), user); + PricingPolicy policy = policy(); + when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy); + + biller.recordAutomationRun(List.of(new FileSize(1, 10L))); + + ArgumentCaptor ctx = ArgumentCaptor.forClass(ChargeContext.class); + verify(jobChargeService).chargeStandalone(ctx.capture(), eq(1)); + assertEquals(JobSource.API, ctx.getValue().source()); + } + + @Test + void noChargeWithoutTeam() { + User user = mock(User.class); + when(user.getTeam()).thenReturn(null); + authenticateAs(mock(Authentication.class), user); + + biller.recordAutomationRun(List.of(new FileSize(1, 10L))); + + verify(jobChargeService, never()) + .chargeStandalone( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + void noChargeForEmptyInputs() { + biller.recordAutomationRun(List.of()); + + verify(jobChargeService, never()) + .chargeStandalone( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt()); + } +} diff --git a/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts b/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts index 4a96532321..82205c52d1 100644 --- a/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts +++ b/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts @@ -5,11 +5,18 @@ import { import { useCallback } from "react"; import { executeAutomationSequence } from "@app/utils/automationExecutor"; import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; +import { useFileContext } from "@app/contexts/FileContext"; import { AutomateParameters } from "@app/types/automation"; +import { + meterAutomationRun, + type AutomationMeterInput, +} from "@app/services/automationMeter"; +import { isStirlingFile } from "@app/types/fileContext"; export function useAutomateOperation() { const { allTools } = useToolRegistry(); const toolRegistry = allTools; + const { selectors } = useFileContext(); const customProcessor = useCallback( async (params: AutomateParameters, files: File[]) => { @@ -21,10 +28,11 @@ export function useAutomateOperation() { if (!params.automationConfig) { throw new Error("No automation configuration provided"); } + const automationConfig = params.automationConfig; // Execute the automation sequence and return the final results const finalResults = await executeAutomationSequence( - params.automationConfig, + automationConfig, files, toolRegistry, (stepIndex: number, operationName: string) => { @@ -47,12 +55,35 @@ export function useAutomateOperation() { console.log( `✅ Automation completed, returning ${finalResults.length} files`, ); + + // Meter the completed run. Charged on the input set's doc-units (once + // per run) so an Automate workflow costs the same as the equivalent policy. + // Best-effort and post-success - never blocks the returned result. + try { + const inputs: AutomationMeterInput[] = files.map((file) => ({ + pages: isStirlingFile(file) + ? (selectors.getStirlingFileStub(file.fileId)?.processedFile + ?.totalPages ?? 0) + : 0, + bytes: file.size ?? 0, + })); + meterAutomationRun({ + automationName: automationConfig.name, + operations: automationConfig.operations.map( + (operation) => operation.operation, + ), + inputs, + }); + } catch (meterError) { + console.warn("Automation metering skipped:", meterError); + } + return { files: finalResults, consumedAllInputs: true, }; }, - [toolRegistry], + [toolRegistry, selectors], ); return useToolOperation( diff --git a/frontend/editor/src/core/services/automationMeter.ts b/frontend/editor/src/core/services/automationMeter.ts new file mode 100644 index 0000000000..629e6e07e9 --- /dev/null +++ b/frontend/editor/src/core/services/automationMeter.ts @@ -0,0 +1,18 @@ +// Records a completed in-browser Automate run for billing/audit. + +/** One input document's page count (0 for non-PDF / unknown) and byte size. */ +export interface AutomationMeterInput { + pages: number; + bytes: number; +} + +export interface AutomationMeterPayload { + automationName?: string; + operations?: string[]; + inputs: AutomationMeterInput[]; +} + +/** Meter a completed Automate run. Fire-and-forget; never awaited, never throws. */ +export function meterAutomationRun(_payload: AutomationMeterPayload): void { + // No billing layer in the core build. +} diff --git a/frontend/editor/src/proprietary/services/automationMeter.ts b/frontend/editor/src/proprietary/services/automationMeter.ts new file mode 100644 index 0000000000..938c0c7111 --- /dev/null +++ b/frontend/editor/src/proprietary/services/automationMeter.ts @@ -0,0 +1,17 @@ +// Meters an in-browser Automate run for billing/audit. + +import apiClient from "@app/services/apiClient"; +import { type AutomationMeterPayload } from "@core/services/automationMeter"; + +export { + type AutomationMeterInput, + type AutomationMeterPayload, +} from "@core/services/automationMeter"; + +export function meterAutomationRun(payload: AutomationMeterPayload): void { + void apiClient + .post(`/api/v1/automate/meter`, payload, { suppressErrorToast: true }) + .catch(() => { + // Best-effort billing; the automation already succeeded in the browser. + }); +}