mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +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 { 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<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