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