diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 9f279a6b8..ce8555efe 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -38,13 +38,19 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.ai.model.AiCreateSession; import stirling.software.saas.ai.repository.AiCreateSessionRepository; import stirling.software.saas.ai.service.AiCreateProxyService; import stirling.software.saas.ai.service.AiCreateSessionService; import stirling.software.saas.payg.cap.RequiresFeature; +import stirling.software.saas.payg.charge.ChargeContext; +import stirling.software.saas.payg.charge.JobChargeService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.model.ProcessType; import stirling.software.saas.service.CreditService; import stirling.software.saas.service.TeamCreditService; import stirling.software.saas.util.AuthenticationUtils; @@ -67,6 +73,7 @@ public class AiCreateController { private final TeamCreditService teamCreditService; private final UserRepository userRepository; private final CreditHeaderUtils creditHeaderUtils; + private final JobChargeService jobChargeService; @PostMapping("/sessions") public ResponseEntity createSession( @@ -87,9 +94,45 @@ public class AiCreateController { session.getUserId(), session.getDocType(), session.getTemplateId()); + chargeForCreate(session); return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId())); } + /** + * Bill one document for a new AI Create session — creating a document is the charge point; + * follow-up edits on the same session (outline / reprompt / draft / template / stream) carry no + * charge. AI usage is billable, so a JWT (web) session counts the same as an API-key one. + * + *

Best-effort: a charge failure must not block the user's session. Entitlement is already + * enforced upstream — this controller is {@code @RequiresFeature(AI_SUPPORT)}, so the + * EntitlementGuard 402s a team with no AI allowance before we ever get here; this call only + * does the accounting (free-grant draw + Stripe meter). + */ + private void chargeForCreate(AiCreateSession session) { + try { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + User user = AuthenticationUtils.getCurrentUser(auth, userRepository); + if (user == null || user.getTeam() == null) { + return; + } + JobSource source = + auth instanceof ApiKeyAuthenticationToken ? JobSource.API : JobSource.WEB; + ChargeContext ctx = + new ChargeContext( + user.getId(), + user.getTeam().getId(), + source, + ProcessType.SINGLE_TOOL, + BillingCategory.AI); + jobChargeService.chargeStandalone(ctx, 1); + } catch (RuntimeException e) { + log.warn( + "AI create session {} charge failed; session proceeds unbilled: {}", + session.getSessionId(), + e.getMessage()); + } + } + @DeleteMapping("/sessions/{sessionId}") public ResponseEntity deleteSession(@PathVariable String sessionId) { sessionService.deleteSessionForCurrentUser(sessionId); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/AiToolRoutes.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/AiToolRoutes.java new file mode 100644 index 000000000..c6a31f389 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/cap/AiToolRoutes.java @@ -0,0 +1,42 @@ +package stirling.software.saas.payg.cap; + +import org.springframework.web.servlet.HandlerMapping; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Central definition of the AI document-tool route namespace ({@code /api/v1/ai/tools/**}). + * + *

These tools (e.g. {@code PdfCommentAgentController}, {@code MathAuditorAgentController}) live + * in the {@code proprietary} module, which does not depend on {@code saas} and therefore cannot + * carry the saas-only {@link RequiresFeature} annotation. Rather than weaken the layering, the PAYG + * hot-path components recognise the path prefix instead: + * + *

+ * + *

Kept as a single source of truth so the interceptor and the guard can never drift on what + * counts as an AI tool. + */ +public final class AiToolRoutes { + + /** Trailing slash so it matches the tool sub-paths, not a bare {@code /api/v1/ai/tools}. */ + public static final String PREFIX = "/api/v1/ai/tools/"; + + private AiToolRoutes() {} + + /** + * True when the request resolved to an AI document-tool endpoint. Prefers the matched route + * pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI. + */ + public static boolean matches(HttpServletRequest request) { + Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + String path = pattern instanceof String s ? s : request.getRequestURI(); + return path != null && path.startsWith(PREFIX); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java index 37875060e..42964e206 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java @@ -133,6 +133,51 @@ public class JobChargeService { return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED); } + /** + * Charge a fixed number of units for a billable action that isn't file/lineage-driven — e.g. an + * AI Create session, billed once per document at session creation. Opens a standalone + * bookkeeping job (no lineage inputs, so follow-up calls never lineage-join it), draws the + * free-grant split, and writes the shadow + ledger rows exactly as {@link #openProcess} does, + * then closes the job so the paid portion meters to Stripe via the same {@code afterCommit} + * path and idempotency key ({@code process::close}). + * + *

Each call is independent: there is no join/dedup, so two sessions charge twice (correct — + * each is a distinct document). The caller passes the unit count; the policy {@code + * minChargeUnits} floor still applies. Must not be called for {@link BillingCategory#BYPASSED}. + * + * @return the bookkeeping job id (mostly useful for tests / tracing) + */ + @Transactional + public UUID chargeStandalone(ChargeContext ctx, int units) { + Objects.requireNonNull(ctx, "ctx"); + if (ctx.billingCategory() == BillingCategory.BYPASSED) { + throw new IllegalArgumentException("chargeStandalone must not be called for BYPASSED"); + } + + PricingPolicy policy = policyService.getEffectivePolicy(ctx.ownerTeamId()); + int chargeUnits = Math.max(units, policy.getMinChargeUnits()); + int stepLimit = resolveStepLimit(policy, ctx.source()); + + JobContext jobCtx = + new JobContext( + ctx.ownerUserId(), + ctx.ownerTeamId(), + ctx.source(), + ctx.processType(), + policy.getId(), + stepLimit); + ProcessingJob job = jobService.open(jobCtx, chargeUnits); + + int freeUsed = consumeFreeGrant(ctx, chargeUnits); + recordShadowRow(ctx, job.getId(), policy.getId(), chargeUnits, freeUsed); + recordLedgerDebit(ctx, job.getId(), policy.getId(), chargeUnits); + + // Close immediately — nothing will lineage-join a standalone job — so the paid portion + // meters via the same afterCommit hook + idempotency key as a normal process completion. + close(job.getId()); + return job.getId(); + } + /** * Draw this job's free portion from the team's one-time lifetime grant, atomically, and return * the units taken (0..{@code units}); the remainder is the paid portion that will be metered to diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index 1c2ad28dc..413fb9f12 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -34,6 +34,7 @@ import stirling.software.common.annotations.AutoJobPostMapping; 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.cap.AiToolRoutes; import stirling.software.saas.payg.cap.RequiresFeature; import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.util.AuthenticationUtils; @@ -132,12 +133,17 @@ public class EntitlementGuard implements HandlerInterceptor { AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null || AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class) != null; - if (!hasAutoJobPostMapping && !hasRequiresFeature) { + // AI document tools (/api/v1/ai/tools/**) live in the proprietary module and can't carry + // @RequiresFeature; recognise them by path so they're gated on AI_SUPPORT — see + // AiToolRoutes and PaygChargeInterceptor, which classify the same routes as AI. + boolean aiToolRoute = AiToolRoutes.matches(request); + if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) { skippedNoAnnotationCounter.increment(); return true; } - FeatureGate[] required = resolveRequiredGates(hm); + FeatureGate[] required = + aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm); Authentication auth = SecurityContextHolder.getContext().getAuthentication(); boolean anonymous = isAnonymous(auth); 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 1006893e3..6989c4e3b 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 @@ -38,6 +38,7 @@ import stirling.software.common.util.TempFileManager; 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.cap.AiToolRoutes; import stirling.software.saas.payg.cap.RequiresFeature; import stirling.software.saas.payg.charge.ChargeContext; import stirling.software.saas.payg.charge.ChargeOutcome; @@ -199,7 +200,10 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { || AnnotationUtils.findAnnotation( hm.getBeanType(), RequiresFeature.class) != null; - if (!hasAutoJobPostMapping && !hasRequiresFeature) { + // 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) { callsShortCircuit.increment(); return true; } @@ -514,11 +518,14 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { /** * Resolve the {@link BillingCategory} for this request. Precedence: {@code * X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION; - * {@code @RequiresFeature(AI_SUPPORT)} → AI; API-key auth → API; otherwise BYPASSED (manual UI - * tool — short-circuited in {@link #preHandle}). + * {@code @RequiresFeature(AI_SUPPORT)} → AI; an AI document-tool route ({@link AiToolRoutes}) → + * AI; API-key auth → API; otherwise BYPASSED (manual UI tool — short-circuited in {@link + * #preHandle}). * *

Method-level {@code @RequiresFeature} wins over class-level. Multiple gates: AUTOMATION - * dominates AI within a single annotation. + * dominates AI within a single annotation. The AI-tool path check sits below the automation + * header on purpose: an AI tool dispatched inside a policy / AI workflow bills as AUTOMATION, + * while a direct call to it bills as AI. */ private static BillingCategory determineCategory( HandlerMethod handler, HttpServletRequest request, Authentication auth) { @@ -545,6 +552,11 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { return BillingCategory.AI; } } + // AI document tools (proprietary module, recognised by path). A direct call bills as AI; an + // orchestrator-dispatched call already returned AUTOMATION above via the automation header. + if (AiToolRoutes.matches(request)) { + return BillingCategory.AI; + } if (auth instanceof ApiKeyAuthenticationToken) { return BillingCategory.API; } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java b/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java index e6a4f04a8..5addb5b76 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java @@ -229,6 +229,20 @@ public class JobService { return new JoinOrOpenResult(saved, JoinOrOpenResult.Disposition.JOINED); } + /** + * Open a standalone process with no lineage inputs, for a billable action that isn't + * file/lineage-driven (e.g. an AI Create session). Because no input signatures are recorded, + * nothing downstream can lineage-join it — each such charge stands alone. {@code docUnits} is + * persisted so the charge service's shadow + ledger rows agree with the job. + */ + @Transactional + public ProcessingJob open(JobContext ctx, int docUnits) { + Objects.requireNonNull(ctx, "ctx"); + ProcessingJob job = openFresh(ctx, Map.of()).job(); + job.setDocUnits(docUnits); + return jobRepository.save(job); + } + private JoinOrOpenResult openFresh( JobContext ctx, Map> signaturesByInput) { ProcessingJob fresh = new ProcessingJob(); diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java index 9a8c7848f..9ff9d7832 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java @@ -825,6 +825,94 @@ class JobChargeServiceTest { verify(jobService).close(jobId); } + // --- chargeStandalone() — non-file billable actions (e.g. AI Create) ----------------------- + + @Test + void chargeStandalone_subscribedTeam_chargesAndMetersPaidPortion() { + // AI Create-style charge: one standalone bookkeeping job, free split, ledger debit, meter. + long teamId = 100L; + PricingPolicy policy = stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(teamId)).thenReturn(policy); + + UUID jobId = UUID.randomUUID(); + when(jobService.open(any(JobContext.class), eq(1))).thenReturn(openJob(jobId)); + when(jobService.close(jobId)).thenReturn(openJob(jobId)); + + // Subscribed, no free grant left → the whole unit is paid and meters. + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(teamId); + ext.setStripeCustomerId("cus_x"); + ext.setPaygSubscriptionId("sub_x"); + ext.setFreeUnitsRemaining(0L); + when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext)); + when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext)); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)) + .thenReturn(Optional.of(chargedShadowRow(jobId, teamId, 1, 0, BillingCategory.AI))); + + ChargeContext ctx = + new ChargeContext( + 7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.AI); + ArgumentCaptor ledger = ArgumentCaptor.forClass(WalletLedgerEntry.class); + + withTransactionSynchronization(() -> service.chargeStandalone(ctx, 1)); + + verify(jobService).open(any(JobContext.class), eq(1)); + verify(jobService).close(jobId); + verify(ledgerRepo).save(ledger.capture()); + assertThat(ledger.getValue().getEntryType()).isEqualTo(LedgerEntryType.DEBIT); + assertThat(ledger.getValue().getAmountUnits()).isEqualTo(-1); + assertThat(ledger.getValue().getBillingCategory()).isEqualTo(BillingCategory.AI); + verify(shadowRepo).save(any(PaygShadowCharge.class)); + // Paid portion (1) metered to Stripe after commit, keyed by the standard process key. + verify(meterReporter) + .recordUsage( + eq(teamId), + eq("cus_x"), + eq(1), + eq(BillingCategory.AI), + eq("process:" + jobId + ":close"), + eq(jobId)); + } + + @Test + void chargeStandalone_freeTeamWithGrant_drawsGrantAndDoesNotMeter() { + long teamId = 100L; + PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(teamId)).thenReturn(policy); + + UUID jobId = UUID.randomUUID(); + when(jobService.open(any(JobContext.class), eq(1))).thenReturn(openJob(jobId)); + when(jobService.close(jobId)).thenReturn(openJob(jobId)); + + // Free grant available, no subscription → the unit is drawn from the grant, nothing meters. + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(teamId); + ext.setFreeUnitsRemaining(50L); + when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext)); + when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext)); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)) + .thenReturn(Optional.of(chargedShadowRow(jobId, teamId, 1, 1, BillingCategory.AI))); + + ChargeContext ctx = + new ChargeContext( + 7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.AI); + + withTransactionSynchronization(() -> service.chargeStandalone(ctx, 1)); + + assertThat(ext.getFreeUnitsRemaining()).isEqualTo(49L); + verify(meterReporter, never()) + .recordUsage(any(), any(), Mockito.anyInt(), any(), any(), any()); + } + + @Test + void chargeStandalone_bypassedCategory_throws() { + ChargeContext ctx = + new ChargeContext( + 7L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.BYPASSED); + assertThatThrownBy(() -> service.chargeStandalone(ctx, 1)) + .isInstanceOf(IllegalArgumentException.class); + } + private static void withTransactionSynchronization(Runnable body) { TransactionSynchronizationManager.initSynchronization(); try { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index 395ad95b9..c692abbe3 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -149,6 +149,55 @@ class EntitlementGuardTest { assertThat(body.get("category").asText()).isEqualTo("AI"); } + @Test + void aiToolRoute_noAnnotation_isInScopeAndGatedOnAiSupport() throws Exception { + // /api/v1/ai/tools/** controllers live in the proprietary module and carry no + // @RequiresFeature; the guard recognises them by path and gates on AI_SUPPORT. A degraded + // team is 402'd even though the handler has no annotation. + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); + + HandlerMethod hm = handlerFor("plainEndpoint"); // no annotations + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/v1/ai/tools/pdf-comment-agent"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(402); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED"); + assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT"); + verify(entitlementService).getSnapshot(42L); + } + + @Test + void aiToolRoute_anonymous_returns401WithAiCategory() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + + HandlerMethod hm = handlerFor("plainEndpoint"); + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/v1/ai/tools/math-auditor-agent"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(401); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED"); + assertThat(body.get("category").asText()).isEqualTo("AI"); + } + // --------------------------------------------------------------------------------------- // Anonymous user // --------------------------------------------------------------------------------------- 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 281b039ca..128c336c3 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 @@ -612,6 +612,73 @@ class PaygChargeInterceptorTest { .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION); } + @Test + void preHandle_aiToolRoute_inScopeAndCategoryAi() throws Exception { + // AI document tools (/api/v1/ai/tools/**) live in the proprietary module and carry no PAYG + // annotation. The interceptor recognises them by path → in scope + AI category, so a direct + // multipart call opens a charge. Handler has NO annotations (handlerMethodForPlain). + 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/ai/tools/pdf-comment-agent"); + req.addFile( + new MockMultipartFile("fileInput", "x.pdf", "application/pdf", "abc".getBytes())); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForPlain()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().billingCategory()) + .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI); + } + + @Test + void preHandle_aiToolRoute_withAutomationHeader_isAutomation() throws Exception { + // An AI tool dispatched inside a policy / AI workflow carries X-Stirling-Automation: true → + // AUTOMATION wins over the AI path rule (the header is checked first). + 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/ai/tools/pdf-comment-agent"); + 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); + } + + @Test + void preHandle_plainRouteNoAnnotations_stillShortCircuits() throws Exception { + // Guard against the path rule being too broad: a non-AI-tools route with no annotations + // must + // still short-circuit (BYPASSED path), unaffected by the AI-tools recognition. + authenticateWithUser(makeUser(7L, 42L)); + + MockMultipartHttpServletRequest req = newMultipart(); // URI = /api/v1/security/test-tool + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + + boolean cont = + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForPlain()); + + assertThat(cont).isTrue(); + verify(chargeService, never()).openProcess(any(), anyList()); + } + // --- helpers -------------------------------------------------------------------------------- private MockMultipartHttpServletRequest newMultipart() {