Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/commands/manual-journals/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
})

Expand Down
2 changes: 1 addition & 1 deletion src/commands/manual-journals/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
})

Expand Down
8 changes: 6 additions & 2 deletions src/lib/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
77 changes: 67 additions & 10 deletions src/lib/validators.ts
Original file line number Diff line number Diff line change
@@ -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'),
Expand Down Expand Up @@ -96,31 +107,71 @@ 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'),
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').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']),
Expand Down Expand Up @@ -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'),
Expand Down
16 changes: 16 additions & 0 deletions test/lib/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
99 changes: 98 additions & 1 deletion test/lib/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,27 @@ 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', () => {
expect(dateSchema.safeParse('01-15-2025').success).toBe(false)
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)
}
})
})

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -540,6 +598,30 @@ describe('journalFileCreateSchema', () => {
expect((result.data.journalLines[1] as Record<string, unknown>).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', () => {
Expand All @@ -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', () => {
Expand Down