mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
fix(payg): gate + charge AI document tools and AI Create sessions (#6617)
## Problem Two AI surfaces slipped through PAYG unbilled: 1. **AI document tools** — `/api/v1/ai/tools/**` (`PdfCommentAgentController`, `MathAuditorAgentController`) live in the **proprietary** module, which can't depend on `saas` and so can't carry the saas-only `@RequiresFeature`. They also lacked `@AutoJobPostMapping`, so the charge interceptor's scope gate short-circuited them **before** category resolution: **not charged, and not even entitlement-gated** — whether called directly or dispatched by the orchestrator. 2. **AI Create** — `/api/v1/ai/create` is JSON/session-based with no file input, so the multipart charge path never fired. The old per-generation charge ran through the now-dead legacy credit system, so it currently charges nothing. ## Fix - **`AiToolRoutes`** (new, saas) — single source of truth for the `/api/v1/ai/tools/**` prefix. The proprietary controllers stay untouched; the saas hot-path recognises them by path: - **`PaygChargeInterceptor`**: brings these routes into scope and bills them **AI** on a direct call. An orchestrator-dispatched call still resolves to **AUTOMATION** first (the `X-Stirling-Automation` header is checked before the path rule), so AI-tool-inside-a-workflow keeps billing as automation. - **`EntitlementGuard`**: gates them on **`AI_SUPPORT`**. - This keeps the `proprietary → saas` layering intact (no backwards dependency). - **`JobChargeService.chargeStandalone(ctx, units)`** — charges a fixed unit count for a non-file billable action, reusing the existing free-grant split + shadow row + ledger debit + `close()`→meter path on a standalone bookkeeping job (no lineage inputs, so nothing lineage-joins it). **`JobService.open(ctx, docUnits)`** opens that bare job. - **`AiCreateController.createSession`** — charges **one document per session** at creation (best-effort; entitlement is already enforced upstream by the class-level `@RequiresFeature(AI_SUPPORT)`). Follow-up edits (`outline` / `reprompt` / `draft` / `template` / `stream`) carry **no** charge — they have no charge hook, so "charge on create, follow-ups free" falls out naturally. Per the agreed scope: charge AI Create on create now; we can optimise follow-up handling later. **AI workflow categorisation (AUTOMATION vs AI) intentionally left as-is** (the orchestrator's automation header dominates by design). ## Tests - `PaygChargeInterceptorTest`: AI-tool route (no annotations) is in scope + **AI** category; same route with the automation header → **AUTOMATION**; a plain non-AI route still short-circuits. - `EntitlementGuardTest`: AI-tool route is in scope + gated on **AI_SUPPORT** (degraded team → 402; anonymous → 401 with `category: AI`). - `JobChargeServiceTest`: `chargeStandalone` charges + meters the paid portion for a subscribed team, draws the free grant (no meter) for an unsubscribed team, and rejects `BYPASSED`. `:saas:test` + `:saas:spotlessCheck` green; coverage gates met. ## Follow-ups (not in this PR) - Make AI Create follow-ups explicitly cheaper / chained if we want (currently free by absence of a hook). - Decide whether AI-tool-inside-a-workflow should bill as AI rather than AUTOMATION.
This commit is contained in:
@@ -38,13 +38,19 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
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.proprietary.security.model.User;
|
||||||
import stirling.software.saas.ai.model.AiCreateSession;
|
import stirling.software.saas.ai.model.AiCreateSession;
|
||||||
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
|
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
|
||||||
import stirling.software.saas.ai.service.AiCreateProxyService;
|
import stirling.software.saas.ai.service.AiCreateProxyService;
|
||||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
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.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.CreditService;
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
import stirling.software.saas.service.TeamCreditService;
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
@@ -67,6 +73,7 @@ public class AiCreateController {
|
|||||||
private final TeamCreditService teamCreditService;
|
private final TeamCreditService teamCreditService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final CreditHeaderUtils creditHeaderUtils;
|
private final CreditHeaderUtils creditHeaderUtils;
|
||||||
|
private final JobChargeService jobChargeService;
|
||||||
|
|
||||||
@PostMapping("/sessions")
|
@PostMapping("/sessions")
|
||||||
public ResponseEntity<CreateSessionResponse> createSession(
|
public ResponseEntity<CreateSessionResponse> createSession(
|
||||||
@@ -87,9 +94,45 @@ public class AiCreateController {
|
|||||||
session.getUserId(),
|
session.getUserId(),
|
||||||
session.getDocType(),
|
session.getDocType(),
|
||||||
session.getTemplateId());
|
session.getTemplateId());
|
||||||
|
chargeForCreate(session);
|
||||||
return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId()));
|
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.
|
||||||
|
*
|
||||||
|
* <p>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}")
|
@DeleteMapping("/sessions/{sessionId}")
|
||||||
public ResponseEntity<Void> deleteSession(@PathVariable String sessionId) {
|
public ResponseEntity<Void> deleteSession(@PathVariable String sessionId) {
|
||||||
sessionService.deleteSessionForCurrentUser(sessionId);
|
sessionService.deleteSessionForCurrentUser(sessionId);
|
||||||
|
|||||||
@@ -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/**}).
|
||||||
|
*
|
||||||
|
* <p>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:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code PaygChargeInterceptor} brings these routes into scope and bills them as {@code
|
||||||
|
* BillingCategory.AI} on a direct call (an orchestrator-dispatched call still resolves to
|
||||||
|
* AUTOMATION first, via the {@code X-Stirling-Automation} header);
|
||||||
|
* <li>{@code EntitlementGuard} gates them on {@link
|
||||||
|
* stirling.software.saas.payg.model.FeatureGate#AI_SUPPORT}.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,51 @@ public class JobChargeService {
|
|||||||
return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED);
|
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:<jobId>:close}).
|
||||||
|
*
|
||||||
|
* <p>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
|
* 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
|
* the units taken (0..{@code units}); the remainder is the paid portion that will be metered to
|
||||||
|
|||||||
+8
-2
@@ -34,6 +34,7 @@ import stirling.software.common.annotations.AutoJobPostMapping;
|
|||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||||
import stirling.software.proprietary.security.model.User;
|
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.cap.RequiresFeature;
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
import stirling.software.saas.payg.model.FeatureGate;
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
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.getMethod(), RequiresFeature.class) != null
|
||||||
|| AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class)
|
|| AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class)
|
||||||
!= null;
|
!= 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();
|
skippedNoAnnotationCounter.increment();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
FeatureGate[] required = resolveRequiredGates(hm);
|
FeatureGate[] required =
|
||||||
|
aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm);
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
boolean anonymous = isAnonymous(auth);
|
boolean anonymous = isAnonymous(auth);
|
||||||
|
|||||||
+16
-4
@@ -38,6 +38,7 @@ import stirling.software.common.util.TempFileManager;
|
|||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||||
import stirling.software.proprietary.security.model.User;
|
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.cap.RequiresFeature;
|
||||||
import stirling.software.saas.payg.charge.ChargeContext;
|
import stirling.software.saas.payg.charge.ChargeContext;
|
||||||
import stirling.software.saas.payg.charge.ChargeOutcome;
|
import stirling.software.saas.payg.charge.ChargeOutcome;
|
||||||
@@ -199,7 +200,10 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
|| AnnotationUtils.findAnnotation(
|
|| AnnotationUtils.findAnnotation(
|
||||||
hm.getBeanType(), RequiresFeature.class)
|
hm.getBeanType(), RequiresFeature.class)
|
||||||
!= null;
|
!= 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();
|
callsShortCircuit.increment();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -514,11 +518,14 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
/**
|
/**
|
||||||
* Resolve the {@link BillingCategory} for this request. Precedence: {@code
|
* Resolve the {@link BillingCategory} for this request. Precedence: {@code
|
||||||
* X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION;
|
* X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION;
|
||||||
* {@code @RequiresFeature(AI_SUPPORT)} → AI; API-key auth → API; otherwise BYPASSED (manual UI
|
* {@code @RequiresFeature(AI_SUPPORT)} → AI; an AI document-tool route ({@link AiToolRoutes}) →
|
||||||
* tool — short-circuited in {@link #preHandle}).
|
* AI; API-key auth → API; otherwise BYPASSED (manual UI tool — short-circuited in {@link
|
||||||
|
* #preHandle}).
|
||||||
*
|
*
|
||||||
* <p>Method-level {@code @RequiresFeature} wins over class-level. Multiple gates: AUTOMATION
|
* <p>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(
|
private static BillingCategory determineCategory(
|
||||||
HandlerMethod handler, HttpServletRequest request, Authentication auth) {
|
HandlerMethod handler, HttpServletRequest request, Authentication auth) {
|
||||||
@@ -545,6 +552,11 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
|||||||
return BillingCategory.AI;
|
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) {
|
if (auth instanceof ApiKeyAuthenticationToken) {
|
||||||
return BillingCategory.API;
|
return BillingCategory.API;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,20 @@ public class JobService {
|
|||||||
return new JoinOrOpenResult(saved, JoinOrOpenResult.Disposition.JOINED);
|
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(
|
private JoinOrOpenResult openFresh(
|
||||||
JobContext ctx, Map<Path, Set<LineageSignature>> signaturesByInput) {
|
JobContext ctx, Map<Path, Set<LineageSignature>> signaturesByInput) {
|
||||||
ProcessingJob fresh = new ProcessingJob();
|
ProcessingJob fresh = new ProcessingJob();
|
||||||
|
|||||||
@@ -825,6 +825,94 @@ class JobChargeServiceTest {
|
|||||||
verify(jobService).close(jobId);
|
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<WalletLedgerEntry> 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) {
|
private static void withTransactionSynchronization(Runnable body) {
|
||||||
TransactionSynchronizationManager.initSynchronization();
|
TransactionSynchronizationManager.initSynchronization();
|
||||||
try {
|
try {
|
||||||
|
|||||||
+49
@@ -149,6 +149,55 @@ class EntitlementGuardTest {
|
|||||||
assertThat(body.get("category").asText()).isEqualTo("AI");
|
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
|
// Anonymous user
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|||||||
+67
@@ -612,6 +612,73 @@ class PaygChargeInterceptorTest {
|
|||||||
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION);
|
.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<stirling.software.saas.payg.charge.ChargeContext> 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<stirling.software.saas.payg.charge.ChargeContext> 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 --------------------------------------------------------------------------------
|
// --- helpers --------------------------------------------------------------------------------
|
||||||
|
|
||||||
private MockMultipartHttpServletRequest newMultipart() {
|
private MockMultipartHttpServletRequest newMultipart() {
|
||||||
|
|||||||
Reference in New Issue
Block a user