diff --git a/app/schemas/invoicejob.schema.js b/app/schemas/invoicejob.schema.js index 6c4a399..c11bdb5 100644 --- a/app/schemas/invoicejob.schema.js +++ b/app/schemas/invoicejob.schema.js @@ -8,16 +8,28 @@ const intIdParam = z.object({ id: z.coerce.number().int().positive(), }); +// `injbAmount` is the per-line monetary value on an invoice. zod's +// `.number()` rejects NaN by default but allows Infinity / -Infinity +// through, and the column is a Sequelize DOUBLE — `inf` lands in the +// database fine and contaminates any consumer doing arithmetic +// (invoice totals, aging buckets, CSV exports). Pin to finite real +// numbers at the boundary. Zero and negative values still pass (a +// $0 reference line and a credit/discount line are both real +// accounting uses). +const injbAmountField = z.coerce.number().finite({ + message: 'injbAmount must be a finite number.', +}); + const createInvoiceJobBody = z.object({ injbInvId: z.coerce.number().int().positive(), injbJobId: z.coerce.number().int().positive(), - injbAmount: z.coerce.number(), + injbAmount: injbAmountField, }).strict({ message: 'Unexpected field in body. Whitelist: injbInvId, injbJobId, injbAmount.', }); const updateInvoiceJobBody = z.object({ - injbAmount: z.coerce.number().optional(), + injbAmount: injbAmountField.optional(), }).strict({ message: 'Unexpected field in body. Whitelist: injbAmount.', }); diff --git a/tests/api/invoicejob.test.js b/tests/api/invoicejob.test.js index ee90a96..623db8c 100644 --- a/tests/api/invoicejob.test.js +++ b/tests/api/invoicejob.test.js @@ -54,4 +54,36 @@ describe('InvoiceJob body validation', () => { const res = await request(app).post('/v1/invoicejob').set('authKey', 'any').send({ injbInvId: 1, injbJobId: 1, injbAmount: 1, bogus: 'no' }); expect(res.status).toBe(400); }); + + test('POST accepts negative injbAmount (credit / discount lines)', async () => { + const res = await request(app).post('/v1/invoicejob').set('authKey', 'any') + .send({ injbInvId: 1, injbJobId: 1, injbAmount: -50 }); + expect(res.status).not.toBe(400); + }); + + test('POST accepts zero injbAmount (reference / informational lines)', async () => { + // Unlike cpayAmount (which rejects 0 because a $0 payment is + // operator error), invoice lines at $0 are a real accounting + // case: a courtesy line, a placeholder, or a service-rendered + // reference. Pin this so a future tightening surfaces here. + const res = await request(app).post('/v1/invoicejob').set('authKey', 'any') + .send({ injbInvId: 1, injbJobId: 1, injbAmount: 0 }); + expect(res.status).not.toBe(400); + }); + + test('PATCH rejects non-finite injbAmount (JSON `null` is the only way to send Infinity through JSON, but a string \\"Infinity\\" hits coerce-to-number)', async () => { + // JSON has no literal for Infinity, but `z.coerce.number()` + // happily turns the string "Infinity" into the float — and + // Sequelize's DOUBLE column will store it as `inf`. The + // .finite() refinement catches it at the schema boundary. + const res = await request(app).patch('/v1/invoicejob/1').set('authKey', 'any') + .send({ injbAmount: 'Infinity' }); + expect(res.status).toBe(400); + }); + + test('PATCH rejects -Infinity injbAmount via the same coerce path', async () => { + const res = await request(app).patch('/v1/invoicejob/1').set('authKey', 'any') + .send({ injbAmount: '-Infinity' }); + expect(res.status).toBe(400); + }); });