From 01ce5597ba352133ad117d914013ff9c8b5108d8 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 19 May 2026 03:27:24 -0500 Subject: [PATCH] feat(customerpayment): reject zero + non-finite cpayAmount at the schema layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cpayAmount` was typed `z.coerce.number()` — which accepts: - **0**: a "\$0 payment" against a customer ledger has no business meaning. It's operator error every time, and the API was happily recording it. - **Infinity / -Infinity**: zod's `.number()` rejects NaN by default but lets the infinities through. A DOUBLE column will store them fine, then any consumer doing arithmetic (totals, aging buckets, CSV exports) gets contaminated. Negative values still pass — some operators model refunds as negative payments, and there's no separate refund flow on this API yet. Pin both that and the zero/infinity rejection in tests so the boundary is observable. Extracted the validator to a named `cpayAmountField` so the create and update bodies share one definition instead of drifting. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/schemas/customerpayment.schema.js | 16 ++++++++++++++-- tests/api/customerpayment.test.js | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/schemas/customerpayment.schema.js b/app/schemas/customerpayment.schema.js index b3c65dd..b731c1f 100644 --- a/app/schemas/customerpayment.schema.js +++ b/app/schemas/customerpayment.schema.js @@ -12,11 +12,23 @@ const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { message: 'Must be an ISO 8601 date (YYYY-MM-DD).', }); +// A payment amount must be a real, non-zero number. Zero is operator +// error (recording a "$0 payment" against a customer ledger has no +// business meaning); Infinity/-Infinity is data-quality error from +// pathological clients — `z.number()` rejects NaN but allows the +// infinities by default, and a DOUBLE column will happily store them. +// Negative values pass — some operators model refunds that way. +const cpayAmountField = z.coerce.number().finite({ + message: 'cpayAmount must be a finite number.', +}).refine((n) => n !== 0, { + message: 'cpayAmount must not be zero.', +}); + const createCustomerPaymentBody = z.object({ cpayCustId: z.coerce.number().int().positive(), cpayDescription: z.string().max(10000).optional(), cpayDate: isoDate, - cpayAmount: z.coerce.number(), + cpayAmount: cpayAmountField, }).strict({ message: 'Unexpected field in body. Whitelist: cpayCustId, cpayDescription, cpayDate, cpayAmount.', }); @@ -24,7 +36,7 @@ const createCustomerPaymentBody = z.object({ const updateCustomerPaymentBody = z.object({ cpayDescription: z.string().max(10000).optional(), cpayDate: isoDate.optional(), - cpayAmount: z.coerce.number().optional(), + cpayAmount: cpayAmountField.optional(), }).strict({ message: 'Unexpected field in body. Whitelist: cpayDescription, cpayDate, cpayAmount.', }); diff --git a/tests/api/customerpayment.test.js b/tests/api/customerpayment.test.js index 60490bf..a461608 100644 --- a/tests/api/customerpayment.test.js +++ b/tests/api/customerpayment.test.js @@ -55,4 +55,25 @@ describe('CustomerPayment body validation', () => { const res = await request(app).post('/v1/customerpayment').set('authKey', 'any').send({ cpayCustId: 1, cpayDate: '2026-01-01' }); expect(res.status).toBe(400); }); + + test('POST rejects zero cpayAmount', async () => { + const res = await request(app).post('/v1/customerpayment').set('authKey', 'any') + .send({ cpayCustId: 1, cpayDate: '2026-01-01', cpayAmount: 0 }); + expect(res.status).toBe(400); + }); + + test('POST accepts a negative cpayAmount (refund model)', async () => { + // Some operators record refunds as negative payments. The + // schema only blocks 0 and the infinities, not negatives — + // pin that so a future tightening surfaces here. + const res = await request(app).post('/v1/customerpayment').set('authKey', 'any') + .send({ cpayCustId: 1, cpayDate: '2026-01-01', cpayAmount: -50 }); + expect(res.status).not.toBe(400); + }); + + test('PATCH rejects zero cpayAmount', async () => { + const res = await request(app).patch('/v1/customerpayment/1').set('authKey', 'any') + .send({ cpayAmount: 0 }); + expect(res.status).toBe(400); + }); });