mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
## 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.