Replaces the ad-hoc, hand-rolled invoice payload checks in src/utils/validators.js with a
single, auditable Zod schema defined in src/schemas/invoice.js, while keeping every existing
caller working unchanged.
Invoice validation was split across two places:
| Location | What it did |
|---|---|
src/utils/validators.js — validateInvoicePayload |
Hand-rolled field-by-field checks; error messages inconsistent, no length bounds, no unknown-key rejection |
src/schemas/invoice.js |
Partial Zod schema without a validateInvoicePayload adapter or complete required-field coverage |
zod was already a project dependency (used in src/jobs/retentionPurge.js), so this was a clear
consolidation win with no new dependency.
The POST /api/invoices route also returned a bare { errors: [...] } array on failure instead
of the RFC 7807 application/problem+json envelope used everywhere else in the API.
invoiceCreateSchema— strict Zod object (.strict()rejects unknown keys) covering all required fields (amount,dueDate,buyer/customer,seller,currency) withsuperRefinecross-field rules. Optional fields (description≤ 1 000 chars,invoiceNumber≤ 100 chars) also validated.invoiceUpdateSchema— partial update schema; all fields optional, unknown keys still rejected.paginationQuerySchema— Zod schema forGET /api/invoicesquery params with coercion and defaults (page = 1,limit = 20).validateInvoicePayload(body)— thin adapter that runsinvoiceCreateSchema.safeParseand returns the existing{ isValid, errors, validatedPayload }shape, so no callers break.parseValidationErrors(zodError)— flattens aZodErrorinto a{ [fieldPath]: firstMessage }object for RFC 7807fieldErrors.validateBody(schema)/validateQuery(schema)— Express middleware factories that attach parsed values toreq.validated/req.validatedQueryand emit RFC 7807 on failure.- All exports carry full TSDoc.
validateInvoicePayloadis now re-exported fromsrc/schemas/invoice.js; the hand-rolled implementation is gone.validateInvoiceQueryParamsandvalidateMarketplaceQueryParams(marketplace path) kept intact — their behavior is unchanged.VALID_CURRENCIESSet is derived from the Zod schema list so both stay in sync automatically.
POST /api/invoicesnow callsinvoiceCreateSchema.safeParsedirectly and returns a proper RFC 7807application/problem+jsonbody withtype,title,status,detail, andfieldErrorson validation failure.
| Concern | How addressed |
|---|---|
| Unknown / prototype-polluting keys | invoiceCreateSchema and invoiceUpdateSchema use .strict() — any unknown key causes an immediate parse failure |
| Oversized strings | buyer/seller ≤ 255 chars; description ≤ 1 000 chars; invoiceNumber ≤ 100 chars |
| Negative / non-finite amounts | Zod .positive() + .finite() — rejects 0, negative, Infinity, NaN |
| Currency injection | Allowlist of 30 ISO 4217 codes; input normalised to upper-case before comparison |
| Invalid dates | Regex /^\d{4}-\d{2}-\d{2}$/ + Date.parse calendar validity check |
| Non-object body | Adapter returns { isValid: false } immediately for null, arrays, or non-objects |
PASS tests/unit/validators.test.js
validateInvoiceQueryParams
✓ should validate valid status
✓ should reject invalid status
✓ should validate valid SME ID
✓ should validate valid Buyer ID
✓ should reject empty Buyer ID
✓ should validate valid dates
✓ should reject invalid date format
✓ should reject logically invalid dates
✓ should validate sorting parameters
✓ should reject invalid sortBy
✓ should reject invalid order
✓ should handle multiple errors
PASS tests/validation.test.js
invoiceCreateSchema
valid payloads
✓ accepts a fully-specified payload
✓ accepts customer as alias for buyer
✓ normalises currency to upper-case
✓ trims whitespace from buyer and seller
required fields
✓ rejects missing amount
✓ rejects missing buyer (no customer either)
✓ rejects missing seller
✓ rejects missing currency
✓ rejects missing dueDate
✓ reports all missing fields in one pass (empty object)
amount validation
✓ rejects zero
✓ rejects negative amount
✓ rejects Infinity
✓ rejects NaN
✓ rejects string amount
dueDate validation
✓ rejects wrong format (DD-MM-YYYY)
✓ rejects impossible date (month 13)
✓ rejects non-string date
buyer / seller string bounds
✓ rejects empty buyer
✓ rejects buyer longer than 255 chars
✓ rejects empty seller
✓ rejects seller longer than 255 chars
currency validation
✓ rejects unsupported code
✓ rejects 2-letter code
✓ rejects 4-letter code
✓ accepts every listed currency
optional field bounds
✓ rejects description longer than 1000 chars
✓ rejects invoiceNumber longer than 100 chars
unknown key rejection (.strict)
✓ rejects a payload with unknown keys
invoiceUpdateSchema
✓ accepts an empty object (full partial update)
✓ accepts a partial update with only amount
✓ rejects negative amount on update
✓ rejects unknown keys
✓ accepts valid status on update
✓ rejects invalid status on update
paginationQuerySchema
✓ applies defaults when nothing is provided
✓ coerces string page to number
✓ rejects page = 0
✓ rejects negative page
✓ rejects non-integer page (1.5)
✓ rejects limit = 0
✓ rejects limit > 100
✓ accepts limit = 100 (boundary)
✓ rejects invalid status
✓ accepts valid status values
✓ rejects invalid dateFrom format
✓ rejects invalid order value
validateInvoicePayload
✓ returns isValid=true and non-empty validatedPayload for a valid body
✓ returns isValid=false and errors array for missing amount
✓ returns isValid=false for negative amount
✓ returns isValid=false for missing buyer
✓ returns isValid=false for unsupported currency
✓ returns isValid=false for oversized description
✓ returns isValid=false for non-object body (null)
✓ returns isValid=false for array body
✓ rejects unknown keys (prototype-pollution attempt)
✓ is the same function as re-exported from validators.js
parseValidationErrors
✓ maps each issue path to the first error message
✓ uses _root for issues with empty path
✓ handles nested paths with dot notation
validateBody middleware
✓ calls next() and attaches req.validated on valid body
✓ returns 400 RFC 7807 response on invalid body
validateQuery middleware
✓ calls next() and attaches req.validatedQuery on valid query
✓ returns 400 RFC 7807 response on invalid query
Test Suites: 2 passed, 2 total
Tests: 76 passed, 76 total
Note on pre-existing test failures: The full
npm testrun shows failures intests/soroban.sim.test.jsand a few other suites. These are confirmed pre-existing onmainbefore any change in this PR (verified viagit stash+ re-run).
npx eslint src/schemas/invoice.js src/utils/validators.js tests/validation.test.js — 0 errors, 0 warnings.
Pre-existing lint errors across other files in the repo are untouched and unchanged.
| File | Change |
|---|---|
src/schemas/invoice.js |
Full rewrite — Zod create/update/query schemas + adapters |
src/utils/validators.js |
Delegates validateInvoicePayload to schema; removes hand-rolled impl |
src/app.js |
POST /api/invoices uses invoiceCreateSchema.safeParse + RFC 7807 errors |
tests/validation.test.js |
Comprehensive edge-case test suite (64 tests) |
closes #268