Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions app/schemas/invoicejob.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
});
Expand Down
32 changes: 32 additions & 0 deletions tests/api/invoicejob.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});