fix(contracts): validate billing_cycle and status at the request layer (422 not 500) (PMS-337) #254

Merged
vas2000-work merged 1 commit from fix/PMS-337-contract-status-billing-validation into main 2026-06-16 01:42:41 +02:00
Owner

Background

Contract create/update accepted any billing_cycle and status string at the request layer. An out-of-set value passed deserialization (both fields are free Strings) and only failed at the DB CHECK constraint in migrations/009_contracts.sql, surfacing to the client as a 500 DATABASE_ERROR instead of a 422.

CHECK sets (confirmed against migrations/009_contracts.sql): status IN (draft, active, expired, cancelled, renewed); billing_cycle IN (monthly, quarterly, annually, one_time).

Goal

Reject out-of-set status and billing_cycle values with a 422 at the request layer, matching the existing validate_contract_type treatment (PMS-299).

Proposed approach

Add two custom validators in src/utils/validation.rs mirroring validate_contract_type (const array plus membership check): validate_contract_status (const CONTRACT_STATUSES) and validate_billing_cycle (const BILLING_CYCLES). Wire them with #[validate(custom(function = ...))] onto CreateContractRequest.status / .billing_cycle (both String) and onto UpdateContractRequest.status / .billing_cycle (both Option<String>, where the custom validator runs on the inner value and skips None).

Alternatives considered

A DB error mapper that translates the CHECK violation into a 422 was rejected: it would couple the error layer to constraint names and still round-trip to Postgres. Request-layer validation fails fast and matches the established pattern.

Acceptance criteria

  • validate_contract_status and validate_billing_cycle added to src/utils/validation.rs, kept in sync with the DB CHECK sets.
  • Both validators wired onto the create and update request structs (only where the field exists).
  • Unit tests assert valid values pass and out-of-set values error, for both validators and both request structs.
  • cargo fmt --all --check, cargo test --lib, and cargo clippy --all-targets -- -D warnings pass.
## Background Contract create/update accepted any `billing_cycle` and `status` string at the request layer. An out-of-set value passed deserialization (both fields are free `String`s) and only failed at the DB CHECK constraint in `migrations/009_contracts.sql`, surfacing to the client as a 500 `DATABASE_ERROR` instead of a 422. CHECK sets (confirmed against `migrations/009_contracts.sql`): `status IN (draft, active, expired, cancelled, renewed)`; `billing_cycle IN (monthly, quarterly, annually, one_time)`. ## Goal Reject out-of-set `status` and `billing_cycle` values with a 422 at the request layer, matching the existing `validate_contract_type` treatment (PMS-299). ## Proposed approach Add two custom validators in `src/utils/validation.rs` mirroring `validate_contract_type` (const array plus membership check): `validate_contract_status` (const `CONTRACT_STATUSES`) and `validate_billing_cycle` (const `BILLING_CYCLES`). Wire them with `#[validate(custom(function = ...))]` onto `CreateContractRequest.status` / `.billing_cycle` (both `String`) and onto `UpdateContractRequest.status` / `.billing_cycle` (both `Option<String>`, where the custom validator runs on the inner value and skips `None`). ## Alternatives considered A DB error mapper that translates the CHECK violation into a 422 was rejected: it would couple the error layer to constraint names and still round-trip to Postgres. Request-layer validation fails fast and matches the established pattern. ## Acceptance criteria - [x] `validate_contract_status` and `validate_billing_cycle` added to `src/utils/validation.rs`, kept in sync with the DB CHECK sets. - [x] Both validators wired onto the create and update request structs (only where the field exists). - [x] Unit tests assert valid values pass and out-of-set values error, for both validators and both request structs. - [x] `cargo fmt --all --check`, `cargo test --lib`, and `cargo clippy --all-targets -- -D warnings` pass.
fix(contracts): validate billing_cycle and status at the request layer (422 not 500)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 35s
Check / fmt + clippy + compile + unit/doc tests (pull_request) Successful in 1m7s
Integration / integration tests (pull_request) Successful in 3m0s
Create release / Create release from merged PR (pull_request) Has been skipped
6d378c9201
Contract create/update accepted any billing_cycle and status string and let invalid values hit the DB CHECK constraint, surfacing as a 500 DATABASE_ERROR; new validate_contract_status and validate_billing_cycle custom validators (mirroring validate_contract_type) reject out-of-set values with a 422 at the request layer and are wired onto both CreateContractRequest and UpdateContractRequest.

#PMS-337

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!254
No description provided.