PAYG: PricingPolicyService + admin REST + 30s read cache (#6469)

## 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.
This commit is contained in:
ConnorYoh
2026-05-29 16:27:01 +00:00
committed by GitHub
parent 2c01f41142
commit 28b81828b5
8 changed files with 1083 additions and 0 deletions
@@ -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.
*
* <p>{@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;
}
}
@@ -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}.
*
* <p>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.
*
* <p><b>Writes are transactional and publish a {@link PolicyChangedEvent} after commit.</b> 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.
*
* <p><b>Cache value is a JPA entity.</b> 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<Long, PricingPolicy> 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.
*
* <p>{@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<PricingPolicy> listAll() {
return policyRepository.findAll();
}
@Transactional(readOnly = true)
public Optional<PricingPolicy> findByVersion(String version) {
return policyRepository.findByVersion(version);
}
@Transactional(readOnly = true)
public Optional<PricingPolicy> 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<Long> overrideId =
teamExtensionsRepository
.findById(teamId)
.map(PaygTeamExtensions::getPricingPolicyId);
if (overrideId.isPresent()) {
Long id = overrideId.get();
Optional<PricingPolicy> 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."));
}
}
@@ -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<JobSource, Integer> stepLimits,
Set<String> 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<JobSource, Integer> stepLimits,
Set<String> 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) {}
}
@@ -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.
*
* <p>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<List<PolicyResponse>> 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<PolicyResponse> 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: <id>} 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<PolicyResponse> 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<String, String> error(String message) {
return java.util.Map.of("error", message == null ? "unknown" : message);
}
}
@@ -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<PricingPolicy, Lo
Optional<PricingPolicy> findByVersion(String version);
Optional<PricingPolicy> 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.
*
* <p>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();
}
@@ -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
);
@@ -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<PolicyChangedEvent> 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;
}
}
@@ -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<List<PolicyResponse>> 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<PolicyResponse> 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<PolicyResponse> 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",
"[email protected]");
PricingPolicy saved = policy(99L, "v2", false);
ArgumentCaptor<PricingPolicy> 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<PolicyResponse> 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;
}
}