From 28b81828b5eee1b12a519cd393a041377a6f71a2 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Fri, 29 May 2026 17:27:01 +0100
Subject: [PATCH] PAYG: PricingPolicyService + admin REST + 30s read cache
(#6469)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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.
---
.../saas/payg/policy/PolicyChangedEvent.java | 28 ++
.../payg/policy/PricingPolicyService.java | 255 +++++++++++++++++
.../saas/payg/policy/admin/PolicyDtos.java | 82 ++++++
.../admin/PricingPolicyAdminController.java | 170 +++++++++++
.../repository/PricingPolicyRepository.java | 13 +
.../saas/V12__seed_default_payg_policy.sql | 37 +++
.../payg/policy/PricingPolicyServiceTest.java | 268 ++++++++++++++++++
.../PricingPolicyAdminControllerTest.java | 230 +++++++++++++++
8 files changed, 1083 insertions(+)
create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/policy/PolicyChangedEvent.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicyService.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PolicyDtos.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminController.java
create mode 100644 app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql
create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/policy/PricingPolicyServiceTest.java
create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PolicyChangedEvent.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PolicyChangedEvent.java
new file mode 100644
index 000000000..0c9e4c58a
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PolicyChangedEvent.java
@@ -0,0 +1,28 @@
+package stirling.software.saas.payg.policy;
+
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * Fires after a successful admin write to a {@code pricing_policy*} or {@code
+ * payg_team_extensions.pricing_policy_id} row. {@link PricingPolicyService} listens and invalidates
+ * its in-process cache so the writer instance reflects the change immediately. Other instances pick
+ * up the change on the next 30-second TTL expiry.
+ *
+ *
{@code payload} is informational only ({@code "create:42"}, {@code "setDefault:7"}, etc.) —
+ * the invalidation strategy is "blow the whole cache" regardless of what changed.
+ */
+public class PolicyChangedEvent extends ApplicationEvent {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String payload;
+
+ public PolicyChangedEvent(Object source, String payload) {
+ super(source);
+ this.payload = payload;
+ }
+
+ public String getPayload() {
+ return payload;
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicyService.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicyService.java
new file mode 100644
index 000000000..e273a391d
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicyService.java
@@ -0,0 +1,255 @@
+package stirling.software.saas.payg.policy;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
+import stirling.software.saas.payg.repository.PricingPolicyRepository;
+
+/**
+ * Read-side facade over {@link PricingPolicyRepository}. The hot-path question is "what pricing
+ * policy applies to this team right now?" — answered by either the team's per-team override (via
+ * {@link PaygTeamExtensions#getPricingPolicyId()}) or the row with {@code is_default = TRUE}.
+ *
+ *
Reads are cached per-{@code teamId} for {@value #CACHE_TTL_SECONDS} seconds. The TTL is the
+ * correctness floor: a policy change is visible on every instance within that window without any
+ * coordination. Admin writes additionally fire a {@link PolicyChangedEvent} after commit so the
+ * instance handling the write sees its own change immediately; other instances pick it up on the
+ * next TTL expiry.
+ *
+ *
Writes are transactional and publish a {@link PolicyChangedEvent} after commit. The
+ * after-commit timing matters: publishing inside the tx would clear caches on instances that
+ * haven't yet seen the row change, racing them into re-reading stale state. After-commit (via
+ * {@link TransactionSynchronizationManager}) guarantees the new state is visible before any
+ * listener fires.
+ *
+ *
Cache value is a JPA entity. Callers must not mutate the returned policy — treat as
+ * read-only. We accept this rather than wrapping in a DTO to keep the PR small; if mutation becomes
+ * a footgun, swap the cache value type for an immutable snapshot.
+ */
+@Service
+@Slf4j
+public class PricingPolicyService {
+
+ static final int CACHE_TTL_SECONDS = 30;
+ private static final int CACHE_MAX_SIZE = 10_000;
+
+ private final PricingPolicyRepository policyRepository;
+ private final PaygTeamExtensionsRepository teamExtensionsRepository;
+ private final ApplicationEventPublisher eventPublisher;
+
+ /**
+ * Cache keyed by {@code teamId}. Null teamId not supported (caller's bug). Value is the
+ * effective policy — either the team's override or the default row.
+ */
+ private final Cache byTeamCache;
+
+ public PricingPolicyService(
+ PricingPolicyRepository policyRepository,
+ PaygTeamExtensionsRepository teamExtensionsRepository,
+ ApplicationEventPublisher eventPublisher) {
+ this.policyRepository = Objects.requireNonNull(policyRepository, "policyRepository");
+ this.teamExtensionsRepository =
+ Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
+ this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher");
+ this.byTeamCache =
+ Caffeine.newBuilder()
+ .maximumSize(CACHE_MAX_SIZE)
+ .expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
+ .recordStats()
+ .build();
+ }
+
+ /**
+ * Resolves the effective policy for {@code teamId}: per-team override if set, else the row with
+ * {@code is_default = TRUE}. Throws {@link IllegalStateException} if no default exists — the
+ * seed migration is expected to put one there.
+ *
+ * {@link Transactional}({@code readOnly = true}) so the eager-loaded {@code stepLimits} and
+ * {@code stripePriceIds} collections initialize inside the same session.
+ */
+ @Transactional(readOnly = true)
+ public PricingPolicy getEffectivePolicy(Long teamId) {
+ Objects.requireNonNull(teamId, "teamId");
+ return byTeamCache.get(teamId, this::loadEffectivePolicy);
+ }
+
+ /** Bypasses the cache. Useful for admin endpoints that want a fresh read after a mutation. */
+ @Transactional(readOnly = true)
+ public PricingPolicy getEffectivePolicyUncached(Long teamId) {
+ Objects.requireNonNull(teamId, "teamId");
+ return loadEffectivePolicy(teamId);
+ }
+
+ /** Lists every policy (admin read). Not cached — admin pages should always see fresh state. */
+ @Transactional(readOnly = true)
+ public List listAll() {
+ return policyRepository.findAll();
+ }
+
+ @Transactional(readOnly = true)
+ public Optional findByVersion(String version) {
+ return policyRepository.findByVersion(version);
+ }
+
+ @Transactional(readOnly = true)
+ public Optional findById(Long policyId) {
+ return policyRepository.findById(policyId);
+ }
+
+ /** Creates a new policy row. Publishes {@link PolicyChangedEvent} after commit. */
+ @Transactional
+ public PricingPolicy create(PricingPolicy draft) {
+ Objects.requireNonNull(draft, "draft");
+ if (draft.getId() != null) {
+ throw new IllegalArgumentException(
+ "Create draft must not carry a policy_id; use update() to modify an existing"
+ + " row.");
+ }
+ if (Boolean.TRUE.equals(draft.getIsDefault())) {
+ // Promotion to default must go through setDefault() so the existing default is
+ // atomically cleared first; otherwise the partial unique index rejects the insert.
+ throw new IllegalArgumentException(
+ "Create with is_default=true is not allowed; create the row then call"
+ + " setDefault(id).");
+ }
+ PricingPolicy saved = policyRepository.save(draft);
+ publishOnCommit("create:" + saved.getId());
+ return saved;
+ }
+
+ /**
+ * Promotes {@code newDefaultId} to be the default policy, atomically clearing the existing
+ * default first. Idempotent — calling with a row already flagged default is a silent no-op (no
+ * event fired; no state actually changed).
+ */
+ @Transactional
+ public PricingPolicy setDefault(Long newDefaultId) {
+ Objects.requireNonNull(newDefaultId, "newDefaultId");
+ PricingPolicy target =
+ policyRepository
+ .findById(newDefaultId)
+ .orElseThrow(
+ () ->
+ new IllegalArgumentException(
+ "No pricing_policy with id " + newDefaultId));
+ if (Boolean.TRUE.equals(target.getIsDefault())) {
+ return target;
+ }
+ policyRepository.clearDefaultFlag();
+ target.setIsDefault(true);
+ PricingPolicy saved = policyRepository.save(target);
+ publishOnCommit("setDefault:" + saved.getId());
+ return saved;
+ }
+
+ /**
+ * Sets {@code teamId}'s per-team policy override. {@code policyId = null} clears the override
+ * (team falls back to default). Validates the policy exists.
+ */
+ @Transactional
+ public void setTeamOverride(Long teamId, Long policyId) {
+ Objects.requireNonNull(teamId, "teamId");
+ if (policyId != null && !policyRepository.existsById(policyId)) {
+ throw new IllegalArgumentException("No pricing_policy with id " + policyId);
+ }
+ PaygTeamExtensions extensions =
+ teamExtensionsRepository
+ .findById(teamId)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "No payg_team_extensions row for team "
+ + teamId
+ + " — should have been created on first"
+ + " PAYG access."));
+ extensions.setPricingPolicyId(policyId);
+ teamExtensionsRepository.save(extensions);
+ publishOnCommit("teamOverride:" + teamId);
+ }
+
+ /**
+ * Invalidates the cache. Called on every {@link PolicyChangedEvent} regardless of which row
+ * changed — cache hit rate is already team-scoped so the cost of a clear is bounded by how many
+ * active teams there are.
+ */
+ @EventListener
+ public void onPolicyChanged(PolicyChangedEvent event) {
+ long evicted = byTeamCache.estimatedSize();
+ byTeamCache.invalidateAll();
+ log.debug(
+ "PricingPolicyService cache invalidated (payload='{}', approx {} entries dropped)",
+ event.getPayload(),
+ evicted);
+ }
+
+ /** Visible for tests. */
+ long cacheSize() {
+ return byTeamCache.estimatedSize();
+ }
+
+ /**
+ * Schedules a {@link PolicyChangedEvent} to fire after the current transaction commits, or
+ * fires immediately if no transaction is active (e.g. test paths calling write methods without
+ * a tx). Inside-transaction firing would have listeners clearing caches before the row change
+ * is visible to other connections — racing them into re-reading stale state.
+ */
+ private void publishOnCommit(String payload) {
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(
+ new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ eventPublisher.publishEvent(
+ new PolicyChangedEvent(PricingPolicyService.this, payload));
+ }
+ });
+ } else {
+ eventPublisher.publishEvent(new PolicyChangedEvent(this, payload));
+ }
+ }
+
+ private PricingPolicy loadEffectivePolicy(Long teamId) {
+ Optional overrideId =
+ teamExtensionsRepository
+ .findById(teamId)
+ .map(PaygTeamExtensions::getPricingPolicyId);
+ if (overrideId.isPresent()) {
+ Long id = overrideId.get();
+ Optional override = policyRepository.findById(id);
+ if (override.isPresent()) {
+ return override.get();
+ }
+ // Override points at a missing policy — log and fall through to default rather than
+ // failing hard. The admin path that sets the override should validate up front; this
+ // is a safety net for racing deletes.
+ log.warn(
+ "Team {} has pricing_policy_id={} set as override but that row is missing;"
+ + " falling back to default.",
+ teamId,
+ id);
+ }
+ return policyRepository
+ .findFirstByIsDefaultTrue()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "No default pricing_policy row found — the V11 seed"
+ + " migration must run before"
+ + " PricingPolicyService is reachable."));
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PolicyDtos.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PolicyDtos.java
new file mode 100644
index 000000000..0069b84dc
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PolicyDtos.java
@@ -0,0 +1,82 @@
+package stirling.software.saas.payg.policy.admin;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import stirling.software.saas.payg.model.JobSource;
+import stirling.software.saas.payg.policy.PricingPolicy;
+
+/**
+ * Request/response DTOs for the pricing-policy admin endpoints. Records rather than the JPA entity
+ * directly so the admin API surface is decoupled from internal columns (e.g. {@code @Version}
+ * optimistic-lock fields, audit timestamps).
+ */
+final class PolicyDtos {
+
+ private PolicyDtos() {}
+
+ /** Outbound representation of a {@link PricingPolicy}. */
+ record PolicyResponse(
+ Long policyId,
+ String version,
+ LocalDateTime effectiveFrom,
+ LocalDateTime effectiveTo,
+ Integer docPagesPerUnit,
+ Long docBytesPerUnit,
+ Integer minChargeUnits,
+ Integer fileUnitCap,
+ Map stepLimits,
+ Set stripePriceIds,
+ Boolean isDefault,
+ String notes,
+ String createdBy,
+ LocalDateTime createdAt) {
+
+ static PolicyResponse from(PricingPolicy p) {
+ return new PolicyResponse(
+ p.getId(),
+ p.getVersion(),
+ p.getEffectiveFrom(),
+ p.getEffectiveTo(),
+ p.getDocPagesPerUnit(),
+ p.getDocBytesPerUnit(),
+ p.getMinChargeUnits(),
+ p.getFileUnitCap(),
+ // Copy the outer collections so a caller's mutation can't leak back into the
+ // cached entity. Values (Integer, String) are immutable, so a shallow copy is
+ // sufficient here.
+ new HashMap<>(p.getStepLimits()),
+ new HashSet<>(p.getStripePriceIds()),
+ p.getIsDefault(),
+ p.getNotes(),
+ p.getCreatedBy(),
+ p.getCreatedAt());
+ }
+ }
+
+ /**
+ * Inbound payload for {@code POST /policies}. {@code stepLimits} and {@code stripePriceIds}
+ * default to empty collections if omitted. {@code effectiveFrom} defaults to {@code now()}.
+ */
+ record CreatePolicyRequest(
+ String version,
+ LocalDateTime effectiveFrom,
+ LocalDateTime effectiveTo,
+ Integer docPagesPerUnit,
+ Long docBytesPerUnit,
+ Integer minChargeUnits,
+ Integer fileUnitCap,
+ Map stepLimits,
+ Set stripePriceIds,
+ String notes,
+ String createdBy) {}
+
+ /**
+ * Inbound payload for {@code PUT /teams/{teamId}/policy-override}. {@code policyId = null}
+ * clears the override (team falls back to default).
+ */
+ record TeamOverrideRequest(Long policyId) {}
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminController.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminController.java
new file mode 100644
index 000000000..0ed2bb417
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminController.java
@@ -0,0 +1,170 @@
+package stirling.software.saas.payg.policy.admin;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.saas.payg.policy.PricingPolicy;
+import stirling.software.saas.payg.policy.PricingPolicyService;
+import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest;
+import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
+import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
+
+/**
+ * Admin-only CRUD for {@link PricingPolicy} rows + per-team override + default-promotion. Every
+ * mutation routes through {@link PricingPolicyService} so the cache invalidation event is published
+ * exactly once per mutation, after commit. Reads return live data (no cache) so admins always see
+ * their own write.
+ *
+ * Path namespace {@code /api/v1/admin/payg/...} matches the design's other admin endpoints
+ * (cap-setting, cohort migration). Every endpoint requires {@code ROLE_ADMIN}.
+ */
+@Hidden
+@RestController
+@RequestMapping("/api/v1/admin/payg")
+@Profile("saas")
+@Tag(name = "PAYG Admin — Pricing Policy", description = "Admin CRUD for pricing policies")
+@RequiredArgsConstructor
+@Slf4j
+public class PricingPolicyAdminController {
+
+ private final PricingPolicyService policyService;
+
+ @GetMapping("/policies")
+ @PreAuthorize("hasRole('ADMIN')")
+ @Operation(summary = "List all pricing policies (admin)")
+ public ResponseEntity> listPolicies() {
+ return ResponseEntity.ok(
+ policyService.listAll().stream().map(PolicyResponse::from).toList());
+ }
+
+ @GetMapping("/policies/{policyId}")
+ @PreAuthorize("hasRole('ADMIN')")
+ @Operation(summary = "Get a single pricing policy by id (admin)")
+ public ResponseEntity getPolicy(@PathVariable Long policyId) {
+ return policyService
+ .findById(policyId)
+ .map(p -> ResponseEntity.ok(PolicyResponse.from(p)))
+ .orElseGet(() -> ResponseEntity.notFound().build());
+ }
+
+ @PostMapping("/policies")
+ @PreAuthorize("hasRole('ADMIN')")
+ @Operation(
+ summary = "Create a new pricing policy (admin)",
+ description =
+ "Creates a non-default policy. To promote to default, call set-default after"
+ + " creation.")
+ public ResponseEntity> createPolicy(@RequestBody CreatePolicyRequest req) {
+ try {
+ PricingPolicy draft = mapCreateRequest(req);
+ PricingPolicy saved = policyService.create(draft);
+ return ResponseEntity.status(HttpStatus.CREATED).body(PolicyResponse.from(saved));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(error(e.getMessage()));
+ }
+ }
+
+ @PostMapping("/policies/{policyId}/set-default")
+ @PreAuthorize("hasRole('ADMIN')")
+ @Operation(
+ summary = "Promote a policy to default (admin)",
+ description =
+ "Atomically clears the existing default flag and sets this row's flag."
+ + " Teams without an override use the default.")
+ public ResponseEntity> setDefault(@PathVariable Long policyId) {
+ try {
+ PricingPolicy promoted = policyService.setDefault(policyId);
+ return ResponseEntity.ok(PolicyResponse.from(promoted));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error(e.getMessage()));
+ }
+ }
+
+ @PutMapping("/teams/{teamId}/policy-override")
+ @PreAuthorize("hasRole('ADMIN')")
+ @Operation(
+ summary = "Set or clear a team's per-team pricing-policy override (admin)",
+ description =
+ "Payload {policyId: } sets the override; {policyId: null} clears it"
+ + " (team falls back to default).")
+ public ResponseEntity> setTeamOverride(
+ @PathVariable Long teamId, @RequestBody TeamOverrideRequest req) {
+ try {
+ policyService.setTeamOverride(teamId, req == null ? null : req.policyId());
+ return ResponseEntity.noContent().build();
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ HttpStatus status =
+ e instanceof IllegalStateException
+ ? HttpStatus.NOT_FOUND
+ : HttpStatus.BAD_REQUEST;
+ return ResponseEntity.status(status).body(error(e.getMessage()));
+ }
+ }
+
+ @GetMapping("/teams/{teamId}/effective-policy")
+ @PreAuthorize("hasRole('ADMIN')")
+ @Operation(
+ summary = "Read the effective policy for a team (admin)",
+ description =
+ "Returns the override if set, else the default. Bypasses the read cache so"
+ + " admins always see the latest state.")
+ public ResponseEntity getEffectivePolicy(@PathVariable Long teamId) {
+ return ResponseEntity.ok(
+ PolicyResponse.from(policyService.getEffectivePolicyUncached(teamId)));
+ }
+
+ private static PricingPolicy mapCreateRequest(CreatePolicyRequest req) {
+ if (req == null) {
+ throw new IllegalArgumentException("Request body required.");
+ }
+ if (req.version() == null || req.version().isBlank()) {
+ throw new IllegalArgumentException("version is required.");
+ }
+ if (req.docPagesPerUnit() == null || req.docBytesPerUnit() == null) {
+ throw new IllegalArgumentException("docPagesPerUnit and docBytesPerUnit are required.");
+ }
+ PricingPolicy p = new PricingPolicy();
+ p.setVersion(req.version());
+ p.setEffectiveFrom(req.effectiveFrom() != null ? req.effectiveFrom() : LocalDateTime.now());
+ p.setEffectiveTo(req.effectiveTo());
+ p.setDocPagesPerUnit(req.docPagesPerUnit());
+ p.setDocBytesPerUnit(req.docBytesPerUnit());
+ p.setMinChargeUnits(req.minChargeUnits() != null ? req.minChargeUnits() : 1);
+ p.setFileUnitCap(req.fileUnitCap() != null ? req.fileUnitCap() : 1000);
+ p.setStepLimits(
+ req.stepLimits() != null ? new HashMap<>(req.stepLimits()) : new HashMap<>());
+ p.setStripePriceIds(
+ req.stripePriceIds() != null
+ ? new HashSet<>(req.stripePriceIds())
+ : new HashSet<>());
+ p.setIsDefault(false);
+ p.setNotes(req.notes());
+ p.setCreatedBy(req.createdBy());
+ return p;
+ }
+
+ private static java.util.Map error(String message) {
+ return java.util.Map.of("error", message == null ? "unknown" : message);
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java
index a1f8cfaaf..0a5b2075e 100644
--- a/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java
+++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PricingPolicyRepository.java
@@ -3,6 +3,8 @@ package stirling.software.saas.payg.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import stirling.software.saas.payg.policy.PricingPolicy;
@@ -13,4 +15,15 @@ public interface PricingPolicyRepository extends JpaRepository findByVersion(String version);
Optional findFirstByIsDefaultTrue();
+
+ /**
+ * Atomically clears the {@code is_default} flag on whichever row currently carries it. Used by
+ * {@code setDefault(newId)} to free the slot before flipping the new row's flag — the {@code
+ * uq_pricing_policy_default} partial unique index would otherwise reject the second row.
+ *
+ * Returns the count of rows updated (0 if no default existed yet, 1 normally).
+ */
+ @Modifying
+ @Query("UPDATE PricingPolicy p SET p.isDefault = false WHERE p.isDefault = true")
+ int clearDefaultFlag();
}
diff --git a/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql b/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql
new file mode 100644
index 000000000..d587a80b2
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql
@@ -0,0 +1,37 @@
+-- Seed the V1 default pricing policy. Idempotent — only inserts when no default row exists.
+-- Units sized so a typical 25-page / 5 MiB document is 1 unit; tune via admin endpoints once
+-- Stripe Prices are wired in production.
+--
+-- This migration is separated from V11 because V11 has already shipped to main — adding rows to
+-- it would change its Flyway checksum and break existing deployments.
+
+INSERT INTO pricing_policy (
+ version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
+ min_charge_units, file_unit_cap, is_default, notes, created_by
+)
+SELECT
+ 'v1-initial', CURRENT_TIMESTAMP, 25, 5242880,
+ 1, 1000, TRUE,
+ 'V1 default seeded by V12 migration. Tune via admin once Stripe Prices are configured.',
+ 'system'
+WHERE NOT EXISTS (
+ SELECT 1 FROM pricing_policy WHERE is_default = TRUE
+);
+
+-- Step limits for the default policy across every JobSource. References the row inserted above
+-- via the partial unique index on is_default=TRUE.
+INSERT INTO pricing_policy_step_limit (policy_id, job_source, step_limit)
+SELECT p.policy_id, src.job_source, src.step_limit
+FROM pricing_policy p
+CROSS JOIN (
+ VALUES
+ ('WEB', 10),
+ ('API', 10),
+ ('PIPELINE', 20), -- automations get a longer chain
+ ('DESKTOP_APP', 10)
+) AS src(job_source, step_limit)
+WHERE p.is_default = TRUE
+ AND NOT EXISTS (
+ SELECT 1 FROM pricing_policy_step_limit s
+ WHERE s.policy_id = p.policy_id AND s.job_source = src.job_source
+ );
diff --git a/app/saas/src/test/java/stirling/software/saas/payg/policy/PricingPolicyServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/policy/PricingPolicyServiceTest.java
new file mode 100644
index 000000000..dace97d36
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/payg/policy/PricingPolicyServiceTest.java
@@ -0,0 +1,268 @@
+package stirling.software.saas.payg.policy;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.springframework.context.ApplicationEventPublisher;
+
+import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
+import stirling.software.saas.payg.repository.PricingPolicyRepository;
+
+/**
+ * Unit tests for {@link PricingPolicyService}: lookup precedence (override → default), cache
+ * hit/miss, invalidation on {@link PolicyChangedEvent}, mutation paths publishing the event.
+ */
+class PricingPolicyServiceTest {
+
+ private PricingPolicyRepository policyRepo;
+ private PaygTeamExtensionsRepository extensionsRepo;
+ private ApplicationEventPublisher events;
+ private PricingPolicyService service;
+
+ private PricingPolicy defaultPolicy;
+ private PricingPolicy overridePolicy;
+
+ @BeforeEach
+ void setUp() {
+ policyRepo = Mockito.mock(PricingPolicyRepository.class);
+ extensionsRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
+ events = Mockito.mock(ApplicationEventPublisher.class);
+ service = new PricingPolicyService(policyRepo, extensionsRepo, events);
+
+ defaultPolicy = policy(1L, "v1-default", true);
+ overridePolicy = policy(2L, "v1-enterprise", false);
+
+ when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.of(defaultPolicy));
+ when(policyRepo.findById(1L)).thenReturn(Optional.of(defaultPolicy));
+ when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy));
+ when(policyRepo.existsById(2L)).thenReturn(true);
+ }
+
+ @Test
+ void noOverride_returnsDefault() {
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
+
+ PricingPolicy result = service.getEffectivePolicy(42L);
+
+ assertThat(result).isEqualTo(defaultPolicy);
+ }
+
+ @Test
+ void overrideSet_returnsOverride() {
+ PaygTeamExtensions ext = new PaygTeamExtensions();
+ ext.setTeamId(42L);
+ ext.setPricingPolicyId(2L);
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
+
+ PricingPolicy result = service.getEffectivePolicy(42L);
+
+ assertThat(result).isEqualTo(overridePolicy);
+ }
+
+ @Test
+ void overridePointsAtMissingPolicy_fallsBackToDefault() {
+ // Race condition: team's override row references a policy that has since been deleted.
+ // Service should log + fall back rather than throw, so the team still gets billed
+ // correctly under the default.
+ PaygTeamExtensions ext = new PaygTeamExtensions();
+ ext.setTeamId(42L);
+ ext.setPricingPolicyId(999L);
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
+ when(policyRepo.findById(999L)).thenReturn(Optional.empty());
+
+ PricingPolicy result = service.getEffectivePolicy(42L);
+
+ assertThat(result).isEqualTo(defaultPolicy);
+ }
+
+ @Test
+ void noDefaultExists_throws() {
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
+ when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.getEffectivePolicy(42L))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("No default pricing_policy row");
+ }
+
+ @Test
+ void secondCallHitsCache_noRepoLookup() {
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
+
+ service.getEffectivePolicy(42L);
+ service.getEffectivePolicy(42L);
+ service.getEffectivePolicy(42L);
+
+ // Three calls, one DB lookup — the cache holds the result.
+ verify(policyRepo, times(1)).findFirstByIsDefaultTrue();
+ verify(extensionsRepo, times(1)).findById(42L);
+ }
+
+ @Test
+ void uncachedRead_alwaysHitsRepo() {
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
+
+ service.getEffectivePolicyUncached(42L);
+ service.getEffectivePolicyUncached(42L);
+
+ verify(policyRepo, times(2)).findFirstByIsDefaultTrue();
+ }
+
+ @Test
+ void policyChangedEvent_invalidatesCache() {
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
+
+ service.getEffectivePolicy(42L);
+ assertThat(service.cacheSize()).isEqualTo(1);
+
+ service.onPolicyChanged(new PolicyChangedEvent(this, "test"));
+
+ assertThat(service.cacheSize()).isZero();
+ // Next call repopulates from DB.
+ service.getEffectivePolicy(42L);
+ verify(policyRepo, times(2)).findFirstByIsDefaultTrue();
+ }
+
+ @Test
+ void create_rejectsDraftWithId() {
+ PricingPolicy draft = policy(99L, "v2", false);
+ assertThatThrownBy(() -> service.create(draft))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must not carry a policy_id");
+ verify(events, never()).publishEvent(any());
+ }
+
+ @Test
+ void create_rejectsDefaultFlagPreSet() {
+ PricingPolicy draft = policy(null, "v2", true);
+ assertThatThrownBy(() -> service.create(draft))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("setDefault");
+ verify(events, never()).publishEvent(any());
+ }
+
+ @Test
+ void create_savesAndPublishesEvent() {
+ PricingPolicy draft = policy(null, "v2-fresh", false);
+ PricingPolicy saved = policy(3L, "v2-fresh", false);
+ when(policyRepo.save(draft)).thenReturn(saved);
+
+ PricingPolicy result = service.create(draft);
+
+ assertThat(result).isEqualTo(saved);
+ ArgumentCaptor evt = ArgumentCaptor.forClass(PolicyChangedEvent.class);
+ verify(events).publishEvent(evt.capture());
+ assertThat(evt.getValue().getPayload()).contains("create:3");
+ }
+
+ @Test
+ void setDefault_promotesAndClearsExisting() {
+ // newDefaultId = 2, current default is 1
+ PricingPolicy promoted = policy(2L, "v1-enterprise", true);
+ when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy));
+ when(policyRepo.save(any(PricingPolicy.class))).thenReturn(promoted);
+
+ PricingPolicy result = service.setDefault(2L);
+
+ verify(policyRepo).clearDefaultFlag();
+ assertThat(result.getIsDefault()).isTrue();
+ verify(events, atLeastOnce()).publishEvent(any(PolicyChangedEvent.class));
+ }
+
+ @Test
+ void setDefault_alreadyDefault_isNoop() {
+ // Calling setDefault on the row that's already default → return it, don't re-flag, but
+ // still don't fire an event (no state change). Keeps callers idempotent without spamming
+ // listeners.
+ PricingPolicy result = service.setDefault(1L);
+
+ assertThat(result).isEqualTo(defaultPolicy);
+ verify(policyRepo, never()).clearDefaultFlag();
+ verify(policyRepo, never()).save(any(PricingPolicy.class));
+ verify(events, never()).publishEvent(any());
+ }
+
+ @Test
+ void setDefault_unknownId_throws() {
+ when(policyRepo.findById(999L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.setDefault(999L))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("No pricing_policy with id 999");
+ verify(events, never()).publishEvent(any());
+ }
+
+ @Test
+ void setTeamOverride_setsAndPublishes() {
+ PaygTeamExtensions ext = new PaygTeamExtensions();
+ ext.setTeamId(42L);
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
+ when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext);
+
+ service.setTeamOverride(42L, 2L);
+
+ assertThat(ext.getPricingPolicyId()).isEqualTo(2L);
+ verify(extensionsRepo).save(ext);
+ verify(events).publishEvent(any(PolicyChangedEvent.class));
+ }
+
+ @Test
+ void setTeamOverride_clearsWithNullPolicyId() {
+ PaygTeamExtensions ext = new PaygTeamExtensions();
+ ext.setTeamId(42L);
+ ext.setPricingPolicyId(2L);
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
+ when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext);
+
+ service.setTeamOverride(42L, null);
+
+ assertThat(ext.getPricingPolicyId()).isNull();
+ }
+
+ @Test
+ void setTeamOverride_unknownPolicyId_throwsBeforeSave() {
+ when(policyRepo.existsById(999L)).thenReturn(false);
+
+ assertThatThrownBy(() -> service.setTeamOverride(42L, 999L))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("No pricing_policy with id 999");
+
+ verify(extensionsRepo, never()).save(any(PaygTeamExtensions.class));
+ verify(events, never()).publishEvent(any());
+ }
+
+ @Test
+ void setTeamOverride_missingExtensionsRow_throws() {
+ when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.setTeamOverride(42L, 2L))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("payg_team_extensions row");
+ }
+
+ private static PricingPolicy policy(Long id, String version, boolean isDefault) {
+ PricingPolicy p = new PricingPolicy();
+ p.setId(id);
+ p.setVersion(version);
+ p.setEffectiveFrom(LocalDateTime.now());
+ p.setDocPagesPerUnit(25);
+ p.setDocBytesPerUnit(5L * 1024 * 1024);
+ p.setMinChargeUnits(1);
+ p.setFileUnitCap(1000);
+ p.setIsDefault(isDefault);
+ return p;
+ }
+}
diff --git a/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java
new file mode 100644
index 000000000..595ce0ecd
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/payg/policy/admin/PricingPolicyAdminControllerTest.java
@@ -0,0 +1,230 @@
+package stirling.software.saas.payg.policy.admin;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import stirling.software.saas.payg.policy.PricingPolicy;
+import stirling.software.saas.payg.policy.PricingPolicyService;
+import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest;
+import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
+import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
+
+/**
+ * Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code
+ * CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's
+ * error mapping (4xx for validation, 404 for missing rows).
+ */
+@ExtendWith(MockitoExtension.class)
+class PricingPolicyAdminControllerTest {
+
+ @Mock private PricingPolicyService service;
+
+ private PricingPolicyAdminController controller;
+
+ @BeforeEach
+ void setUp() {
+ controller = new PricingPolicyAdminController(service);
+ }
+
+ @Test
+ void listPolicies_returnsAll() {
+ when(service.listAll())
+ .thenReturn(List.of(policy(1L, "v1", true), policy(2L, "v2", false)));
+
+ ResponseEntity> resp = controller.listPolicies();
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody()).hasSize(2);
+ assertThat(resp.getBody().get(0).version()).isEqualTo("v1");
+ }
+
+ @Test
+ void getPolicy_returnsOk() {
+ when(service.findById(1L)).thenReturn(Optional.of(policy(1L, "v1", true)));
+
+ ResponseEntity resp = controller.getPolicy(1L);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody().policyId()).isEqualTo(1L);
+ }
+
+ @Test
+ void getPolicy_missingReturns404() {
+ when(service.findById(999L)).thenReturn(Optional.empty());
+
+ ResponseEntity resp = controller.getPolicy(999L);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+
+ @Test
+ void createPolicy_happyPath() {
+ CreatePolicyRequest req =
+ new CreatePolicyRequest(
+ "v2",
+ LocalDateTime.now(),
+ null,
+ 25,
+ 5L * 1024 * 1024,
+ 1,
+ 1000,
+ null,
+ null,
+ "notes",
+ "admin@example.com");
+ PricingPolicy saved = policy(99L, "v2", false);
+ ArgumentCaptor draft = ArgumentCaptor.forClass(PricingPolicy.class);
+ when(service.create(draft.capture())).thenReturn(saved);
+
+ ResponseEntity> resp = controller.createPolicy(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
+ assertThat(((PolicyResponse) resp.getBody()).policyId()).isEqualTo(99L);
+ assertThat(draft.getValue().getVersion()).isEqualTo("v2");
+ // Controller must never let isDefault=true through to the service — setDefault is the
+ // only path for promotion.
+ assertThat(draft.getValue().getIsDefault()).isFalse();
+ }
+
+ @Test
+ void createPolicy_missingVersion_returns400() {
+ CreatePolicyRequest req =
+ new CreatePolicyRequest(
+ null, null, null, 25, 5L * 1024 * 1024, 1, 1000, null, null, null, null);
+
+ ResponseEntity> resp = controller.createPolicy(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void createPolicy_missingDocFields_returns400() {
+ CreatePolicyRequest req =
+ new CreatePolicyRequest(
+ "v2", null, null, null, null, 1, 1000, null, null, null, null);
+
+ ResponseEntity> resp = controller.createPolicy(req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void setDefault_returnsOk() {
+ when(service.setDefault(2L)).thenReturn(policy(2L, "v2-promoted", true));
+
+ ResponseEntity> resp = controller.setDefault(2L);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(((PolicyResponse) resp.getBody()).isDefault()).isTrue();
+ }
+
+ @Test
+ void setDefault_unknownId_returns404() {
+ when(service.setDefault(999L))
+ .thenThrow(new IllegalArgumentException("No pricing_policy with id 999"));
+
+ ResponseEntity> resp = controller.setDefault(999L);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+
+ @Test
+ void setTeamOverride_noContent() {
+ TeamOverrideRequest req = new TeamOverrideRequest(2L);
+
+ ResponseEntity> resp = controller.setTeamOverride(42L, req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
+ verify(service).setTeamOverride(42L, 2L);
+ }
+
+ @Test
+ void setTeamOverride_nullBody_clearsOverride() {
+ // Curl with no body, or {} → req == null is handled as "clear".
+ ResponseEntity> resp = controller.setTeamOverride(42L, null);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
+ verify(service).setTeamOverride(42L, null);
+ }
+
+ @Test
+ void setTeamOverride_unknownPolicy_returns400() {
+ TeamOverrideRequest req = new TeamOverrideRequest(999L);
+ org.mockito.Mockito.doThrow(new IllegalArgumentException("No pricing_policy with id 999"))
+ .when(service)
+ .setTeamOverride(42L, 999L);
+
+ ResponseEntity> resp = controller.setTeamOverride(42L, req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ void setTeamOverride_missingTeamExtensions_returns404() {
+ TeamOverrideRequest req = new TeamOverrideRequest(2L);
+ org.mockito.Mockito.doThrow(new IllegalStateException("No payg_team_extensions row"))
+ .when(service)
+ .setTeamOverride(42L, 2L);
+
+ ResponseEntity> resp = controller.setTeamOverride(42L, req);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ }
+
+ @Test
+ void getEffectivePolicy_bypassesCache() {
+ when(service.getEffectivePolicyUncached(42L)).thenReturn(policy(1L, "v1", true));
+
+ ResponseEntity resp = controller.getEffectivePolicy(42L);
+
+ assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(resp.getBody().version()).isEqualTo("v1");
+ verify(service).getEffectivePolicyUncached(42L);
+ }
+
+ @Test
+ void policyResponse_collectionsAreDefensiveCopies() {
+ PricingPolicy p = policy(1L, "v1", true);
+ p.setStepLimits(new java.util.HashMap<>(Map.of()));
+ p.setStripePriceIds(new java.util.HashSet<>());
+
+ PolicyResponse resp = PolicyResponse.from(p);
+
+ // Mutating the source after building the response should not affect the response.
+ p.getStepLimits().put(stirling.software.saas.payg.model.JobSource.WEB, 99);
+ p.getStripePriceIds().add("price_xyz");
+ assertThat(resp.stepLimits()).isEmpty();
+ assertThat(resp.stripePriceIds()).isEmpty();
+ }
+
+ private static PricingPolicy policy(Long id, String version, boolean isDefault) {
+ PricingPolicy p = new PricingPolicy();
+ p.setId(id);
+ p.setVersion(version);
+ p.setEffectiveFrom(LocalDateTime.now());
+ p.setDocPagesPerUnit(25);
+ p.setDocBytesPerUnit(5L * 1024 * 1024);
+ p.setMinChargeUnits(1);
+ p.setFileUnitCap(1000);
+ p.setIsDefault(isDefault);
+ return p;
+ }
+}