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
23 changes: 21 additions & 2 deletions app/schemas/invoice.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,41 @@ const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
message: 'Must be an ISO 8601 date (YYYY-MM-DD).',
});

// Cross-field refinement: an invoice's due date must be on or
// after its issue date. Mirrors the teEndedAt >= teStartedAt
// constraint from #130 — a due-before-issue invoice is operator
// error worth surfacing at the boundary instead of papering over.
// Equality (due same day as issue) is allowed: zero-day-net is a
// real billing term ("Due on Receipt").
//
// String comparison is safe here because `isoDate` is the strict
// `YYYY-MM-DD` regex above; lexicographic order on that shape
// matches chronological order for any valid input.
function refineDueDateAfterIssue(data) {
if (!data.invDate || !data.invDueDate) return true;
return data.invDueDate >= data.invDate;
}
const DUE_BEFORE_ISSUE = {
message: 'invDueDate must be on or after invDate.',
path: ['invDueDate'],
};

const createInvoiceBody = z.object({
invCustId: z.coerce.number().int().positive(),
invDate: isoDate,
invDueDate: isoDate,
invPaid: z.boolean().optional(),
}).strict({
message: 'Unexpected field in body. Whitelist: invCustId, invDate, invDueDate, invPaid.',
});
}).refine(refineDueDateAfterIssue, DUE_BEFORE_ISSUE);

const updateInvoiceBody = z.object({
invDate: isoDate.optional(),
invDueDate: isoDate.optional(),
invPaid: z.boolean().optional(),
}).strict({
message: 'Unexpected field in body. Whitelist: invDate, invDueDate, invPaid.',
});
}).refine(refineDueDateAfterIssue, DUE_BEFORE_ISSUE);

const listByCustomerQuery = z.object({
limit: z.coerce.number().int().positive().max(500).optional(),
Expand Down
63 changes: 63 additions & 0 deletions tests/api/invoice.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,67 @@ describe('Invoice body validation', () => {
const res = await request(app).post('/v1/invoice').set('authKey', 'any').send({ invCustId: 1, invDate: 'tomorrow', invDueDate: '2026-02-01' });
expect(res.status).toBe(400);
});

test('POST rejects invDueDate strictly before invDate', async () => {
// Inverted range — due date *before* issue date is nonsense. Pin
// the refinement so a future schema refactor can't accidentally
// drop the check.
const res = await request(app).post('/v1/invoice').set('authKey', 'any').send({
invCustId: 1,
invDate: '2026-05-15',
invDueDate: '2026-05-01',
});
expect(res.status).toBe(400);
const issue = res.body.issues && res.body.issues.find((i) => i.path === 'invDueDate');
expect(issue).toBeDefined();
expect(issue.message).toMatch(/on or after invDate/i);
});

test('POST accepts invDueDate equal to invDate (zero-day-net / "Due on Receipt")', async () => {
// Equality is a legitimate billing term, not a bug. Schema must
// not 400 — auth/controller decides the final status from there.
const res = await request(app).post('/v1/invoice').set('authKey', 'any').send({
invCustId: 1,
invDate: '2026-05-15',
invDueDate: '2026-05-15',
});
expect(res.status).not.toBe(400);
});

test('PATCH rejects inverted range when both bounds are sent', async () => {
const res = await request(app).patch('/v1/invoice/1').set('authKey', 'any').send({
invDate: '2026-05-15',
invDueDate: '2026-05-01',
});
expect(res.status).toBe(400);
const issue = res.body.issues && res.body.issues.find((i) => i.path === 'invDueDate');
expect(issue).toBeDefined();
});

test('PATCH with only invDueDate is not blocked by the schema', async () => {
// The cross-field refinement can't validate a single-bound PATCH
// without seeing the existing row; the schema must not reject
// it. Controller-layer enforcement against the existing invDate
// is a separate follow-up.
const res = await request(app).patch('/v1/invoice/1').set('authKey', 'any').send({
invDueDate: '2026-05-01',
});
expect(res.status).not.toBe(400);
});

test('bulk POST rejects an inverted-range entry inside the batch', async () => {
// The bulk path validates each element through createInvoiceBody,
// so the refinement must fire there too — anything else would
// let an attacker bypass the check by wrapping the bad entry in
// a bulk envelope.
const res = await request(app).post('/v1/invoice/bulk').set('authKey', 'any').send({
invoices: [
{ invCustId: 1, invDate: '2026-05-15', invDueDate: '2026-05-01' },
],
});
expect(res.status).toBe(400);
// Path on a bulk entry's issue: `invoices.0.invDueDate`.
const issue = res.body.issues && res.body.issues.find((i) => i.path.endsWith('invDueDate'));
expect(issue).toBeDefined();
});
});