From d365ce97ded6f25c7f21f94f4d495ca88ea944ec Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 19 May 2026 04:00:35 -0500 Subject: [PATCH] feat(invoicejob): reject non-finite injbAmount at the schema layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `injbAmount` was typed `z.coerce.number()`, which accepts every value `Number()` produces — including `Infinity` and `-Infinity`. zod's `.number()` rejects `NaN` by default but lets the infinities slip through. JSON has no literal for Infinity, but `z.coerce.number()` happily turns the string `"Infinity"` into the float, and the underlying Sequelize `DOUBLE` column will store it as `inf`. Any downstream consumer doing arithmetic on the column (invoice totals, aging buckets, CSV exports) then gets contaminated. Pin `.finite()` at the boundary so non-finite values get a clean 400 instead of corrupting the ledger. Zero and negative values still pass — both are real accounting cases on invoice lines (reference/courtesy $0 lines; credit/discount negative lines). This is intentionally looser than cpayAmount's non-zero refinement from the customer-payment side: a $0 payment is operator error, a $0 invoice line is normal. Extracted the validator to a named `injbAmountField` so create and update bodies share one definition (mirrors the `cpayAmountField` pattern from #172). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/schemas/invoicejob.schema.js | 16 ++++++++++++++-- tests/api/invoicejob.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) 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); + }); });