diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java index b758c89787..99300d6fcf 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java @@ -202,7 +202,14 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { // AI document tools (/api/v1/ai/tools/**) live in the proprietary module and can't // carry @RequiresFeature, so they're recognised by path — see AiToolRoutes. boolean aiToolRoute = AiToolRoutes.matches(request); - if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) { + // Any internal automation sub-step (X-Stirling-Automation) is billable automation + // whatever controller it lands on: integration/third-party steps and other proprietary + // tools carry no annotation. Matches self-hosted, which bills by the header regardless. + boolean automationSubStep = hasAutomationHeader(request); + if (!hasAutoJobPostMapping + && !hasRequiresFeature + && !aiToolRoute + && !automationSubStep) { callsShortCircuit.increment(); return true; } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java index be402dd061..8dab9f741b 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java @@ -725,6 +725,32 @@ class PaygChargeInterceptorTest { verify(chargeService, never()).openProcess(any(), anyList()); } + @Test + void preHandle_plainRouteWithAutomationHeader_isAutomation() throws Exception { + // A policy/pipeline sub-step to an unannotated proprietary tool (e.g. an integration step) + // carries X-Stirling-Automation: true. It must be in scope and billed AUTOMATION, matching + // self-hosted, which bills by the header regardless of annotation. + authenticateWithUser(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.setRequestURI("/api/v1/integration/external-api-call"); + req.addFile( + new MockMultipartFile("fileInput", "x.pdf", "application/pdf", "abc".getBytes())); + req.addHeader("X-Stirling-Automation", "true"); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForPlain()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().billingCategory()) + .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION); + } + // --- helpers -------------------------------------------------------------------------------- private MockMultipartHttpServletRequest newMultipart() {