Skip to content

Commit 17e6eab

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): preserve accounting amount precision
1 parent 590d2d5 commit 17e6eab

2 files changed

Lines changed: 51 additions & 12 deletions

File tree

apps/sim/tools/quickbooks/accounting.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,18 @@ describe('QuickBooks accounting line validation', () => {
169169
expect(() =>
170170
parseQuickBooksJournalLines([{ ...journalLines[0], raw: true }, journalLines[1]])
171171
).toThrow('unsupported field')
172+
expect(() =>
173+
parseQuickBooksJournalLines([
174+
{ ...journalLines[0], amount: '0.10000000000000001' },
175+
{ ...journalLines[1], amount: '0.1' },
176+
])
177+
).toThrow('more than two decimal places')
178+
expect(() =>
179+
parseQuickBooksJournalLines([
180+
{ ...journalLines[0], amount: '90071992547409.91' },
181+
{ ...journalLines[1], amount: '90071992547409.91' },
182+
])
183+
).toThrow('safely supported amount range')
172184
expect(() =>
173185
parseQuickBooksJournalLines(
174186
Array.from({ length: 102 }, (_, index) => ({

apps/sim/tools/quickbooks/accounting_utils.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,34 @@ function rejectUnknownKeys(
5959
if (unknownKey) throw new Error(`${fieldName} contains unsupported field "${unknownKey}"`)
6060
}
6161

62-
function positiveNumber(value: unknown, fieldName: string): number {
63-
const parsed = typeof value === 'number' ? value : Number(value)
64-
if (!Number.isFinite(parsed) || parsed <= 0) {
62+
function positiveQuickBooksAmount(
63+
value: unknown,
64+
fieldName: string
65+
): { decimal: Decimal; number: number } {
66+
if (typeof value !== 'number' && typeof value !== 'string') {
6567
throw new Error(`${fieldName} must be a positive finite number`)
6668
}
67-
return parsed
69+
const normalized = typeof value === 'string' ? value.trim() : value
70+
if (normalized === '') throw new Error(`${fieldName} must be a positive finite number`)
71+
72+
let decimal: Decimal
73+
try {
74+
decimal = new Decimal(normalized)
75+
} catch {
76+
throw new Error(`${fieldName} must be a positive finite number`)
77+
}
78+
if (!decimal.isFinite() || decimal.lte(0)) {
79+
throw new Error(`${fieldName} must be a positive finite number`)
80+
}
81+
if (decimal.decimalPlaces() > 2) {
82+
throw new Error(`${fieldName} cannot have more than two decimal places`)
83+
}
84+
85+
const number = decimal.toNumber()
86+
if (!Number.isFinite(number) || !new Decimal(number).equals(decimal)) {
87+
throw new Error(`${fieldName} is outside the safely supported amount range`)
88+
}
89+
return { decimal, number }
6890
}
6991

7092
function stringValue(value: unknown, fieldName: string): string {
@@ -97,6 +119,7 @@ export function parseQuickBooksJournalLines(
97119
if (!parsed) return undefined
98120
validateLineCount(parsed, fieldName, 2)
99121

122+
const decimalAmounts: Decimal[] = []
100123
const lines = parsed.map((rawLine, index) => {
101124
const itemName = `${fieldName}[${index}]`
102125
const line = requireObject(rawLine, itemName)
@@ -117,9 +140,11 @@ export function parseQuickBooksJournalLines(
117140
if ((entityType === undefined) !== (line.entityId === undefined)) {
118141
throw new Error(`${itemName}.entityType and entityId must be supplied together`)
119142
}
143+
const amount = positiveQuickBooksAmount(line.amount, `${itemName}.amount`)
144+
decimalAmounts.push(amount.decimal)
120145
return {
121146
postingType,
122-
amount: positiveNumber(line.amount, `${itemName}.amount`),
147+
amount: amount.number,
123148
accountId: stringValue(line.accountId, `${itemName}.accountId`),
124149
description: optionalStringValue(line.description, `${itemName}.description`),
125150
entityType,
@@ -130,12 +155,14 @@ export function parseQuickBooksJournalLines(
130155
}
131156
})
132157

133-
const debitTotal = lines
134-
.filter((line) => line.postingType === 'debit')
135-
.reduce((sum, line) => sum.plus(line.amount), new Decimal(0))
136-
const creditTotal = lines
137-
.filter((line) => line.postingType === 'credit')
138-
.reduce((sum, line) => sum.plus(line.amount), new Decimal(0))
158+
const debitTotal = lines.reduce(
159+
(sum, line, index) => (line.postingType === 'debit' ? sum.plus(decimalAmounts[index]) : sum),
160+
new Decimal(0)
161+
)
162+
const creditTotal = lines.reduce(
163+
(sum, line, index) => (line.postingType === 'credit' ? sum.plus(decimalAmounts[index]) : sum),
164+
new Decimal(0)
165+
)
139166
if (!debitTotal.equals(creditTotal)) {
140167
throw new Error('Journal entry debit and credit totals must balance')
141168
}
@@ -182,7 +209,7 @@ export function parseQuickBooksDepositLines(
182209
const line = requireObject(rawLine, itemName)
183210
rejectUnknownKeys(line, DEPOSIT_LINE_KEYS, itemName)
184211
return {
185-
amount: positiveNumber(line.amount, `${itemName}.amount`),
212+
amount: positiveQuickBooksAmount(line.amount, `${itemName}.amount`).number,
186213
accountId: stringValue(line.accountId, `${itemName}.accountId`),
187214
description: optionalStringValue(line.description, `${itemName}.description`),
188215
}

0 commit comments

Comments
 (0)