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.
+ });
+}