## What this is PR-I1 service half from `notes/PAYG_DESIGN.md`. Built on top of the data model from #6460 — answers "what pricing policy applies to this team right now?" with a fast cache and an admin write surface. ## Scope | Piece | Where | |---|---| | `PricingPolicyService` — `getEffectivePolicy(teamId)` with 30s Caffeine cache + admin write paths | `app/saas/.../payg/policy/PricingPolicyService.java` | | `PolicyChangedEvent` — published after admin writes for in-process cache invalidation | `app/saas/.../payg/policy/PolicyChangedEvent.java` | | Admin REST — list / get / create / set-default / set team override / get effective | `app/saas/.../payg/policy/admin/PricingPolicyAdminController.java` + DTOs | | `PricingPolicyRepository.clearDefaultFlag()` — atomic clear for set-default | repository update | | `SaasJpaConfigScanTest` — drift guard against the JPA scan paths going stale (carried over from the #6460 review concern) | new test | | V12 default-policy seed (`v1-initial`, 25 pages/unit, 5 MiB/unit, per-`JobSource` step limits) | `V12__seed_default_payg_policy.sql` | ## Lookup precedence 1. `PaygTeamExtensions.pricingPolicyId` set → return that policy 2. Else return the `pricing_policy` row with `is_default = TRUE` 3. Override row points at a deleted policy → log warn, fall back to default (safety net for racing deletes) 4. No default → `IllegalStateException` (V12 seed guarantees one exists) ## Cache behaviour - 30s `expireAfterWrite` Caffeine, max 10k entries, keyed by `teamId`. - **Single correctness model: the TTL.** Cross-instance propagation is at-most-30-seconds. The writer instance sees its own change immediately via the `PolicyChangedEvent` after-commit publish. Other instances pick it up on the next TTL expiry. - Admin reads use `getEffectivePolicyUncached` so admins always see their own write straight back. **Why no LISTEN/NOTIFY runner.** An earlier cut of this PR included a Postgres `LISTEN policy_changed` runner so cross-instance propagation was instant. Dropped — admin policy changes are events-per-week and the 30s TTL is already the correctness floor; the listener was ~250 lines of nontrivial code (raw JDBC outside HikariCP, daemon thread, reconnect loop, lock-protected connection lifecycle) for a use case that isn't on the hot path. Trade-off is documented in `notes/PAYG_DESIGN.md` §9 with three concrete triggers that would justify reintroducing it (aggressive cap enforcement, Redis landing for other reasons, real-time admin UI). ## Writes — transactional, fire `PolicyChangedEvent` after commit - `create(draft)` — rejects pre-set `policy_id` or `is_default=true` (promotion must go through `setDefault` so the partial unique idx is freed first). - `setDefault(id)` — atomically clears the existing default via `clearDefaultFlag()` then flips the new row. Idempotent: silent no-op if the row is already default. - `setTeamOverride(teamId, policyId | null)` — validates the policy exists before save; `null` clears the override. `publishOnCommit` uses `TransactionSynchronizationManager.afterCommit` so listeners never see pre-commit state. Outside a transaction (test paths) falls through to immediate publish. ## Admin REST surface — `/api/v1/admin/payg/...` All endpoints `@PreAuthorize("hasRole('ADMIN')")`: - `GET /policies` — list all - `GET /policies/{id}` — read one - `POST /policies` — create new (non-default) - `POST /policies/{id}/set-default` — atomic promote - `PUT /teams/{teamId}/policy-override` — set or clear per-team override - `GET /teams/{teamId}/effective-policy` — cache-bypassing live read Validation errors → 400, unknown rows → 404. ## Counterpart Supabase PR [`Stirling-PDF-SaaS#298`](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/298) — seeds the same V1 default policy on the Supabase side via `20260528000002_payg_seed_default_policy.sql`. ## Tests - 17 × `PricingPolicyServiceTest` — lookup precedence, cache hit/miss, invalidation on event, mutation paths publishing event, error cases. - 14 × `PricingPolicyAdminControllerTest` — every endpoint's happy path + error mapping, DTO defensive-copy invariant. - 2 × `SaasJpaConfigScanTest` — reflection-based guard that `payg.repository` is in `@EnableJpaRepositories` and `payg` is in `@EntityScan`. Without this, new sub-packages can silently fail to wire at runtime — same class of bug that the #6460 review caught. Full `:saas:test` BUILD SUCCESSFUL. ## Design doc `notes/PAYG_DESIGN.md` §7.4 PR-I1 — completes the service half (the schema half landed in #6460). §9 carries the 30s-TTL trade-off note.
Stirling PDF - The Open-Source PDF Platform
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services.
Key Capabilities
- Everywhere you work - Desktop client, browser UI, and self-hosted server with a private API.
- 50+ PDF tools - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
- Automation & workflows - No-code pipelines direct in UI with APIs to process millions of PDFs.
- Enterprise‑grade - SSO, auditing, and flexible on‑prem deployments.
- Developer platform - REST APIs available for nearly all tools to integrate into your existing systems.
- Global UI - Interface available in 40+ languages.
For a full feature list, see the docs: https://docs.stirlingpdf.com
Quick Start
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
Then open: http://localhost:8080
For full installation options (including desktop and Kubernetes), see our Documentation Guide.
Resources
Support
- Community Discord
- Bug Reports: Github issues
Contributing
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
This project uses Task as a unified command runner for all build, dev, and test commands. Run task install to get started, or see the Developer Guide for full details.
For adding translations, see the Translation Guide.
License
Stirling PDF is open-core. See LICENSE for details.

