mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Add metering for Automate
This commit is contained in:
+63
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<UsageMeterService> meterProvider;
|
||||||
|
|
||||||
|
public AccountLinkAutomationRunBiller(
|
||||||
|
EntitlementCache entitlementCache, ObjectProvider<UsageMeterService> meterProvider) {
|
||||||
|
this.entitlementCache = entitlementCache;
|
||||||
|
this.meterProvider = meterProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void recordAutomationRun(List<FileSize> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -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<AutomationRunBiller> biller;
|
||||||
|
|
||||||
|
public AutomationMeterController(ObjectProvider<AutomationRunBiller> 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<Void> meterAutomationRun(
|
||||||
|
@RequestBody(required = false) AutomationMeterRequest body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
List<FileSize> 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<String> 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<FileSize> sanitizeInputs(AutomationMeterRequest body) {
|
||||||
|
if (body == null || body.inputs() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<FileSize> 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<String> sanitizeOperations(AutomationMeterRequest body) {
|
||||||
|
if (body == null || body.operations() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> 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<String> operations, List<InputDoc> inputs) {}
|
||||||
|
|
||||||
|
/** One input document's page count (0 for non-PDF / unknown) and byte size. */
|
||||||
|
public record InputDoc(Integer pages, Long bytes) {}
|
||||||
|
}
|
||||||
+23
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<FileSize> inputs);
|
||||||
|
}
|
||||||
+116
@@ -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<UsageMeterService> providerOf(UsageMeterService meter) {
|
||||||
|
ObjectProvider<UsageMeterService> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -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<AutomationRunBiller> providerOf(AutomationRunBiller biller) {
|
||||||
|
ObjectProvider<AutomationRunBiller> provider = mock(ObjectProvider.class);
|
||||||
|
when(provider.getIfAvailable()).thenReturn(biller);
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AutomationMeterRequest req(List<InputDoc> 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<List<FileSize>> 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<InputDoc> many =
|
||||||
|
IntStream.range(0, 10_050).mapToObj(i -> new InputDoc(-5, -1L)).toList();
|
||||||
|
controller.meterAutomationRun(
|
||||||
|
new AutomationMeterRequest(null, null, many), new MockHttpServletRequest());
|
||||||
|
|
||||||
|
ArgumentCaptor<List<FileSize>> captor = ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(biller).recordAutomationRun(captor.capture());
|
||||||
|
List<FileSize> billed = captor.getValue();
|
||||||
|
assertEquals(10_000, billed.size());
|
||||||
|
assertTrue(billed.stream().allMatch(f -> f.pages() == 0 && f.bytes() == 0L));
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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<FileSize> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -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<ChargeContext> 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<ChargeContext> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,18 @@ import {
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { executeAutomationSequence } from "@app/utils/automationExecutor";
|
import { executeAutomationSequence } from "@app/utils/automationExecutor";
|
||||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||||
|
import { useFileContext } from "@app/contexts/FileContext";
|
||||||
import { AutomateParameters } from "@app/types/automation";
|
import { AutomateParameters } from "@app/types/automation";
|
||||||
|
import {
|
||||||
|
meterAutomationRun,
|
||||||
|
type AutomationMeterInput,
|
||||||
|
} from "@app/services/automationMeter";
|
||||||
|
import { isStirlingFile } from "@app/types/fileContext";
|
||||||
|
|
||||||
export function useAutomateOperation() {
|
export function useAutomateOperation() {
|
||||||
const { allTools } = useToolRegistry();
|
const { allTools } = useToolRegistry();
|
||||||
const toolRegistry = allTools;
|
const toolRegistry = allTools;
|
||||||
|
const { selectors } = useFileContext();
|
||||||
|
|
||||||
const customProcessor = useCallback(
|
const customProcessor = useCallback(
|
||||||
async (params: AutomateParameters, files: File[]) => {
|
async (params: AutomateParameters, files: File[]) => {
|
||||||
@@ -21,10 +28,11 @@ export function useAutomateOperation() {
|
|||||||
if (!params.automationConfig) {
|
if (!params.automationConfig) {
|
||||||
throw new Error("No automation configuration provided");
|
throw new Error("No automation configuration provided");
|
||||||
}
|
}
|
||||||
|
const automationConfig = params.automationConfig;
|
||||||
|
|
||||||
// Execute the automation sequence and return the final results
|
// Execute the automation sequence and return the final results
|
||||||
const finalResults = await executeAutomationSequence(
|
const finalResults = await executeAutomationSequence(
|
||||||
params.automationConfig,
|
automationConfig,
|
||||||
files,
|
files,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
(stepIndex: number, operationName: string) => {
|
(stepIndex: number, operationName: string) => {
|
||||||
@@ -47,12 +55,35 @@ export function useAutomateOperation() {
|
|||||||
console.log(
|
console.log(
|
||||||
`✅ Automation completed, returning ${finalResults.length} files`,
|
`✅ 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 {
|
return {
|
||||||
files: finalResults,
|
files: finalResults,
|
||||||
consumedAllInputs: true,
|
consumedAllInputs: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[toolRegistry],
|
[toolRegistry, selectors],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useToolOperation<AutomateParameters>(
|
return useToolOperation<AutomateParameters>(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user