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; + } +}