From 840787686ea1356816b7c7672d476a0cbcfe31b5 Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:56:00 +1000 Subject: [PATCH] fix: validate dates and draft manual journals --- src/commands/manual-journals/create.ts | 2 +- src/commands/manual-journals/update.ts | 2 +- src/lib/formatters.ts | 8 ++- src/lib/validators.ts | 77 +++++++++++++++++--- test/lib/formatters.test.ts | 16 +++++ test/lib/validators.test.ts | 99 +++++++++++++++++++++++++- 6 files changed, 189 insertions(+), 15 deletions(-) diff --git a/src/commands/manual-journals/create.ts b/src/commands/manual-journals/create.ts index 147afbf..9ef51a8 100644 --- a/src/commands/manual-journals/create.ts +++ b/src/commands/manual-journals/create.ts @@ -26,7 +26,7 @@ export default class ManualJournalsCreate extends BaseCommand { } const result = await this.xeroCall(flags, async (xero, tenantId) => { - const response = await xero.accountingApi.createManualJournals(tenantId, {manualJournals: [fileData as unknown as ManualJournal]}) + const response = await xero.accountingApi.createManualJournals(tenantId, {manualJournals: [parsed.data as unknown as ManualJournal]}) return response.body.manualJournals?.[0] }) diff --git a/src/commands/manual-journals/update.ts b/src/commands/manual-journals/update.ts index 6cbf8eb..9908ea9 100644 --- a/src/commands/manual-journals/update.ts +++ b/src/commands/manual-journals/update.ts @@ -28,7 +28,7 @@ export default class ManualJournalsUpdate extends BaseCommand { const manualJournalID = parsed.data.manualJournalID const result = await this.xeroCall(flags, async (xero, tenantId) => { - const response = await xero.accountingApi.updateManualJournal(tenantId, manualJournalID, {manualJournals: [fileData as unknown as ManualJournal]}) + const response = await xero.accountingApi.updateManualJournal(tenantId, manualJournalID, {manualJournals: [parsed.data as unknown as ManualJournal]}) return response.body.manualJournals?.[0] }) diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 637bd5c..ae497a2 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -107,9 +107,13 @@ export function formatDate(date: unknown): string { if (!date) return '' if (typeof date === 'string') { // Handle Xero's /Date(...)/ format - const msMatch = /\/Date\((\d+)\+\d+\)\//.exec(date) + const msMatch = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(date) if (msMatch) { - return new Date(Number(msMatch[1])).toISOString().split('T')[0] + const milliseconds = Number(msMatch[1]) + const parsed = new Date(milliseconds) + if (Number.isFinite(milliseconds) && !Number.isNaN(parsed.getTime())) { + return parsed.toISOString().split('T')[0] + } } // Already a date string if (/^\d{4}-\d{2}-\d{2}/.test(date)) { diff --git a/src/lib/validators.ts b/src/lib/validators.ts index b2db45c..10528ac 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -1,6 +1,17 @@ import {z} from 'zod' -export const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format') +export const dateSchema = z.string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format') + .refine((value) => { + const [year, month, day] = value.split('-').map(Number) + if (year < 1) return false + const candidate = new Date(0) + candidate.setUTCHours(0, 0, 0, 0) + candidate.setUTCFullYear(year, month - 1, day) + return candidate.getUTCFullYear() === year + && candidate.getUTCMonth() === month - 1 + && candidate.getUTCDate() === day + }, 'Date must be a real calendar date') export const lineItemSchema = z.object({ description: z.string().min(1, 'Description is required'), @@ -96,20 +107,60 @@ export const creditNoteUpdateSchema = z.object({ export const journalLineSchema = z.object({ accountCode: z.string().min(1, 'Account code is required'), - lineAmount: z.number(), + lineAmount: z.number().finite('Line amount must be finite'), description: z.string().optional(), taxType: z.string().optional(), }) +function amountToMinorUnits(amount: number): bigint | null { + if (!Number.isFinite(amount)) return null + const match = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(String(amount)) + if (!match) return null + const [, sign, integer, fraction = '', exponentText = '0'] = match + const digits = BigInt(`${integer}${fraction}`) + const power = Number(exponentText) - fraction.length + 2 + if (!Number.isSafeInteger(power)) return null + + let minorUnits: bigint + if (power >= 0) { + minorUnits = digits * 10n ** BigInt(power) + } else { + const divisor = 10n ** BigInt(-power) + if (digits % divisor !== 0n) return null + minorUnits = digits / divisor + } + return sign === '-' ? -minorUnits : minorUnits +} + +function journalLinesAreBalanced(lines: Array<{lineAmount: number}>): boolean { + const amounts = lines.map((line) => amountToMinorUnits(line.lineAmount)) + return amounts.every((amount): amount is bigint => amount !== null) + && amounts.reduce((total, amount) => total + amount, 0n) === 0n +} + +function addJournalBalanceIssue( + lines: Array<{lineAmount: number}>, + ctx: z.RefinementCtx, + path: string, +): void { + if (!journalLinesAreBalanced(lines)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Journal lines must use exact cents and balance to zero', + path: [path], + }) + } +} + export const journalCreateSchema = z.object({ narration: z.string().min(1, 'Narration is required'), manualJournalLines: z.array(journalLineSchema).min(2, 'At least two journal lines are required'), date: dateSchema.optional(), lineAmountTypes: z.enum(['EXCLUSIVE', 'INCLUSIVE', 'NO_TAX']).optional(), - status: z.enum(['DRAFT', 'POSTED', 'DELETED', 'VOIDED', 'ARCHIVED']).optional(), + status: z.literal('DRAFT').default('DRAFT'), url: z.string().url().optional(), showOnCashBasisReports: z.boolean().optional(), -}) +}).superRefine((journal, ctx) => addJournalBalanceIssue(journal.manualJournalLines, ctx, 'manualJournalLines')) export const journalUpdateSchema = z.object({ manualJournalID: z.string().min(1, 'Manual journal ID is required'), @@ -117,10 +168,10 @@ export const journalUpdateSchema = z.object({ manualJournalLines: z.array(journalLineSchema).min(2, 'At least two journal lines are required'), date: dateSchema.optional(), lineAmountTypes: z.enum(['EXCLUSIVE', 'INCLUSIVE', 'NO_TAX']).optional(), - status: z.enum(['DRAFT', 'POSTED', 'DELETED', 'VOIDED', 'ARCHIVED']).optional(), + status: z.literal('DRAFT').optional(), url: z.string().url().optional(), showOnCashBasisReports: z.boolean().optional(), -}) +}).superRefine((journal, ctx) => addJournalBalanceIssue(journal.manualJournalLines, ctx, 'manualJournalLines')) export const bankTransactionCreateSchema = z.object({ type: z.enum(['RECEIVE', 'SPEND']), @@ -266,16 +317,22 @@ export const accountFileUpdateSchema = z.object({ accountID: z.string().min(1, 'Account ID is required'), }).passthrough() +const journalFileLineSchema = z.object({ + lineAmount: z.number().finite('Line amount must be finite'), +}).passthrough() + export const journalFileCreateSchema = z.object({ narration: z.string().min(1, 'Narration is required'), - journalLines: z.array(z.object({}).passthrough()).min(2, 'At least two journal lines are required'), -}).passthrough() + journalLines: z.array(journalFileLineSchema).min(2, 'At least two journal lines are required'), + status: z.literal('DRAFT').default('DRAFT'), +}).passthrough().superRefine((journal, ctx) => addJournalBalanceIssue(journal.journalLines, ctx, 'journalLines')) export const journalFileUpdateSchema = z.object({ manualJournalID: z.string().min(1, 'Manual journal ID is required'), narration: z.string().min(1, 'Narration is required'), - journalLines: z.array(z.object({}).passthrough()).min(2, 'At least two journal lines are required'), -}).passthrough() + journalLines: z.array(journalFileLineSchema).min(2, 'At least two journal lines are required'), + status: z.literal('DRAFT').optional(), +}).passthrough().superRefine((journal, ctx) => addJournalBalanceIssue(journal.journalLines, ctx, 'journalLines')) export const trackingOptionsFileUpdateSchema = z.object({ trackingCategoryId: z.string().min(1, 'Tracking category ID is required'), diff --git a/test/lib/formatters.test.ts b/test/lib/formatters.test.ts index 4b88ce1..8f42b17 100644 --- a/test/lib/formatters.test.ts +++ b/test/lib/formatters.test.ts @@ -83,6 +83,22 @@ describe('formatDate', () => { it('formats Xero /Date()/ format', () => { // Jan 1, 2025 00:00:00 UTC = 1735689600000 expect(formatDate('/Date(1735689600000+0000)/')).toBe('2025-01-01') + expect(formatDate('/Date(1735689600000-1100)/')).toBe('2025-01-01') + expect(formatDate('/Date(1735689600000)/')).toBe('2025-01-01') + expect(formatDate('/Date(0+1000)/')).toBe('1970-01-01') + expect(formatDate('/Date(-86400000-1000)/')).toBe('1969-12-31') + }) + + it('rejects malformed or out-of-range Xero date wrappers gracefully', () => { + for (const malformed of [ + '/Date(0+1000)/extra', + 'prefix/Date(0+1000)/', + '/Date(0+100)/', + '/Date(not-a-number)/', + '/Date(999999999999999999999)/', + ]) { + expect(formatDate(malformed)).toBe(malformed) + } }) it('formats Date objects', () => { diff --git a/test/lib/validators.test.ts b/test/lib/validators.test.ts index 102fd43..8834ba9 100644 --- a/test/lib/validators.test.ts +++ b/test/lib/validators.test.ts @@ -30,6 +30,9 @@ describe('dateSchema', () => { it('accepts valid YYYY-MM-DD dates', () => { expect(dateSchema.safeParse('2025-01-15').success).toBe(true) expect(dateSchema.safeParse('2025-12-31').success).toBe(true) + expect(dateSchema.safeParse('2024-02-29').success).toBe(true) + expect(dateSchema.safeParse('0001-01-01').success).toBe(true) + expect(dateSchema.safeParse('9999-12-31').success).toBe(true) }) it('rejects invalid date formats', () => { @@ -37,6 +40,17 @@ describe('dateSchema', () => { expect(dateSchema.safeParse('2025/01/15').success).toBe(false) expect(dateSchema.safeParse('not-a-date').success).toBe(false) expect(dateSchema.safeParse('').success).toBe(false) + for (const invalid of [ + '0000-01-01', + '2023-02-29', + '2025-00-10', + '2025-13-10', + '2025-01-00', + '2025-01-32', + '10000-01-01', + ]) { + expect(dateSchema.safeParse(invalid).success).toBe(false) + } }) }) @@ -192,6 +206,48 @@ describe('journalCreateSchema', () => { ], }).success).toBe(false) }) + + it('defaults to DRAFT and rejects consequential create statuses', () => { + const journal = { + narration: 'Test journal', + manualJournalLines: [ + {accountCode: '200', lineAmount: 100}, + {accountCode: '400', lineAmount: -100}, + ], + } + const parsed = journalCreateSchema.safeParse(journal) + + expect(parsed.success && parsed.data.status).toBe('DRAFT') + expect(journalCreateSchema.safeParse({...journal, status: 'POSTED'}).success).toBe(false) + }) + + it('rejects non-finite, sub-cent, and imbalanced line amounts', () => { + const journal = { + narration: 'Test journal', + manualJournalLines: [ + {accountCode: '200', lineAmount: 100}, + {accountCode: '400', lineAmount: -100}, + ], + } + + expect(journalCreateSchema.safeParse({ + ...journal, + manualJournalLines: [{accountCode: '200', lineAmount: Number.POSITIVE_INFINITY}, ...journal.manualJournalLines], + }).success).toBe(false) + expect(journalCreateSchema.safeParse({ + ...journal, + manualJournalLines: [{accountCode: '200', lineAmount: 1.001}, {accountCode: '400', lineAmount: -1.001}], + }).success).toBe(false) + expect(journalCreateSchema.safeParse({ + ...journal, + manualJournalLines: [{accountCode: '200', lineAmount: 0.1}, {accountCode: '400', lineAmount: 0.2}, {accountCode: '500', lineAmount: -0.3}], + }).success).toBe(true) + expect(journalCreateSchema.safeParse({ + ...journal, + lineAmountTypes: 'INCLUSIVE', + manualJournalLines: [{accountCode: '200', lineAmount: 100}, {accountCode: '400', lineAmount: -99.99}], + }).success).toBe(false) + }) }) describe('formatZodError', () => { @@ -507,7 +563,9 @@ describe('journalFileCreateSchema', () => { {lineAmount: -100, accountCode: '400'}, ], } - expect(journalFileCreateSchema.safeParse(data).success).toBe(true) + const parsed = journalFileCreateSchema.safeParse(data) + expect(parsed.success).toBe(true) + expect(parsed.success && parsed.data.status).toBe('DRAFT') }) it('rejects missing narration', () => { @@ -540,6 +598,30 @@ describe('journalFileCreateSchema', () => { expect((result.data.journalLines[1] as Record).trackingCategories).toEqual([{name: 'Dept'}]) } }) + + it('rejects POSTED, non-finite, sub-cent, and imbalanced file input', () => { + const journal = { + narration: 'Test', + journalLines: [ + {lineAmount: 100, accountCode: '200'}, + {lineAmount: -100, accountCode: '400'}, + ], + } + + expect(journalFileCreateSchema.safeParse({...journal, status: 'POSTED'}).success).toBe(false) + expect(journalFileCreateSchema.safeParse({ + ...journal, + journalLines: [{lineAmount: Number.NEGATIVE_INFINITY}, {lineAmount: 100}, {lineAmount: -100}], + }).success).toBe(false) + expect(journalFileCreateSchema.safeParse({ + ...journal, + journalLines: [{lineAmount: 1.001}, {lineAmount: -1.001}], + }).success).toBe(false) + expect(journalFileCreateSchema.safeParse({ + ...journal, + journalLines: [{lineAmount: 100}, {lineAmount: -99.99}], + }).success).toBe(false) + }) }) describe('journalFileUpdateSchema', () => { @@ -564,6 +646,21 @@ describe('journalFileUpdateSchema', () => { ], }).success).toBe(false) }) + + it('allows only DRAFT and requires exact-cent balance on update', () => { + const journal = { + manualJournalID: 'mj-123', + narration: 'Updated', + journalLines: [{lineAmount: 50}, {lineAmount: -50}], + } + + expect(journalFileUpdateSchema.safeParse({...journal, status: 'DRAFT'}).success).toBe(true) + expect(journalFileUpdateSchema.safeParse({...journal, status: 'POSTED'}).success).toBe(false) + expect(journalFileUpdateSchema.safeParse({ + ...journal, + journalLines: [{lineAmount: 50}, {lineAmount: -49.99}], + }).success).toBe(false) + }) }) describe('trackingOptionsFileUpdateSchema', () => {