Skip to content

Commit 6ab4784

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): harden linked bills and payments
1 parent 1512602 commit 6ab4784

8 files changed

Lines changed: 345 additions & 14 deletions

File tree

apps/sim/lib/core/config/env-capabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1189,7 +1189,7 @@ export const OAUTH_CLIENT_CAPABILITIES = {
11891189
hubspot: ['HUBSPOT_CLIENT_ID', 'HUBSPOT_CLIENT_SECRET'],
11901190
linkedin: ['LINKEDIN_CLIENT_ID', 'LINKEDIN_CLIENT_SECRET'],
11911191
instagram: ['INSTAGRAM_CLIENT_ID', 'INSTAGRAM_CLIENT_SECRET'],
1192-
quickbooks: ['QUICKBOOKS_CLIENT_ID', 'QUICKBOOKS_CLIENT_SECRET'],
1192+
quickbooks: ['QUICKBOOKS_CLIENT_ID', 'QUICKBOOKS_CLIENT_SECRET', 'QUICKBOOKS_ENV'],
11931193
salesforce: ['SALESFORCE_CLIENT_ID', 'SALESFORCE_CLIENT_SECRET'],
11941194
shopify: ['SHOPIFY_CLIENT_ID', 'SHOPIFY_CLIENT_SECRET'],
11951195
zoom: ['ZOOM_CLIENT_ID', 'ZOOM_CLIENT_SECRET'],
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
buildQuickBooksCreateBillBody,
4+
verifyQuickBooksBillLinks,
5+
} from '@/tools/quickbooks/purchasing_utils'
6+
import type { QuickBooksCreateBillParams } from '@/tools/quickbooks/types'
7+
8+
const BASE_PARAMS: QuickBooksCreateBillParams = {
9+
accessToken: 'token',
10+
realmId: 'realm',
11+
vendorId: 'vendor-1',
12+
lines: [{ lineType: 'account', amount: 20, accountId: 'account-1' }],
13+
}
14+
15+
describe('QuickBooks Create Bill Purchase Order linking', () => {
16+
it('keeps the standalone Bill payload free of linked transactions', () => {
17+
const body = buildQuickBooksCreateBillBody(BASE_PARAMS)
18+
19+
expect(body).not.toHaveProperty('LinkedTxn')
20+
expect(body.Line).toEqual([
21+
{
22+
Amount: 20,
23+
DetailType: 'AccountBasedExpenseLineDetail',
24+
AccountBasedExpenseLineDetail: {
25+
AccountRef: { value: 'account-1' },
26+
},
27+
},
28+
])
29+
})
30+
31+
it('adds unique transaction-level PO links and exact line-level PO links', () => {
32+
const body = buildQuickBooksCreateBillBody({
33+
...BASE_PARAMS,
34+
lines: [
35+
{
36+
lineType: 'account',
37+
amount: 10,
38+
accountId: 'account-1',
39+
purchaseOrderId: ' po-2 ',
40+
purchaseOrderLineId: ' line-1 ',
41+
},
42+
{
43+
lineType: 'account',
44+
amount: 5,
45+
accountId: 'account-1',
46+
purchaseOrderId: 'po-2',
47+
purchaseOrderLineId: 'line-2',
48+
},
49+
{ lineType: 'account', amount: 3, accountId: 'account-1' },
50+
{
51+
lineType: 'item',
52+
amount: 2,
53+
itemId: 'item-1',
54+
purchaseOrderId: 'po-1',
55+
purchaseOrderLineId: 'line-3',
56+
},
57+
],
58+
})
59+
60+
expect(body.LinkedTxn).toEqual([
61+
{ TxnId: 'po-2', TxnType: 'PurchaseOrder' },
62+
{ TxnId: 'po-1', TxnType: 'PurchaseOrder' },
63+
])
64+
expect(body.Line).toMatchObject([
65+
{
66+
LinkedTxn: [{ TxnId: 'po-2', TxnType: 'PurchaseOrder', TxnLineId: 'line-1' }],
67+
},
68+
{
69+
LinkedTxn: [{ TxnId: 'po-2', TxnType: 'PurchaseOrder', TxnLineId: 'line-2' }],
70+
},
71+
{ Amount: 3 },
72+
{
73+
LinkedTxn: [{ TxnId: 'po-1', TxnType: 'PurchaseOrder', TxnLineId: 'line-3' }],
74+
},
75+
])
76+
expect((body.Line as Array<Record<string, unknown>>)[2]).not.toHaveProperty('LinkedTxn')
77+
})
78+
79+
it('reports successful linkage when QuickBooks returns every requested pair', () => {
80+
const lines = [
81+
{
82+
lineType: 'account' as const,
83+
amount: 20,
84+
accountId: 'account-1',
85+
purchaseOrderId: 'po-1',
86+
purchaseOrderLineId: 'po-line-1',
87+
},
88+
]
89+
90+
expect(
91+
verifyQuickBooksBillLinks(
92+
{
93+
Id: 'bill-1',
94+
Line: [
95+
{
96+
Id: 'bill-line-1',
97+
LinkedTxn: [
98+
{
99+
TxnId: 'po-1',
100+
TxnType: 'PurchaseOrder',
101+
TxnLineId: 'po-line-1',
102+
},
103+
],
104+
},
105+
],
106+
},
107+
lines,
108+
'bill-1'
109+
)
110+
).toEqual({
111+
linkingRequested: true,
112+
linkingSucceeded: true,
113+
linkedLines: [
114+
{
115+
purchaseOrderId: 'po-1',
116+
purchaseOrderLineId: 'po-line-1',
117+
billLineId: 'bill-line-1',
118+
},
119+
],
120+
missingLinks: [],
121+
})
122+
})
123+
})

apps/sim/tools/quickbooks/purchasing_utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,10 +391,21 @@ export function buildQuickBooksCreateBillBody(
391391
): Record<string, unknown> {
392392
const lines = parseQuickBooksBillLines(params.lines)
393393
if (!lines) throw new Error('lines are required')
394+
const purchaseOrderIds = [
395+
...new Set(lines.flatMap((line) => (line.purchaseOrderId ? [line.purchaseOrderId] : []))),
396+
]
394397
return {
395398
...purchasingHeader(params),
396399
VendorRef: quickBooksReference(params.vendorId, 'vendorId'),
397400
Line: buildValidatedQuickBooksBillLines(lines),
401+
...(purchaseOrderIds.length > 0
402+
? {
403+
LinkedTxn: purchaseOrderIds.map((TxnId) => ({
404+
TxnId,
405+
TxnType: 'PurchaseOrder',
406+
})),
407+
}
408+
: {}),
398409
}
399410
}
400411

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import {
3+
buildQuickBooksCreatePaymentBody,
4+
buildQuickBooksUpdatePaymentBody,
5+
parseQuickBooksInvoiceAllocations,
6+
parseQuickBooksSalesLines,
7+
} from '@/tools/quickbooks/sales_utils'
8+
import { quickbooksUpdateCustomerPaymentTool } from '@/tools/quickbooks/update_customer_payment'
9+
10+
describe('QuickBooks sales monetary validation', () => {
11+
it.each([
12+
['boolean', false],
13+
['over-precision value', 1.001],
14+
['non-finite value', Number.POSITIVE_INFINITY],
15+
['unsafe magnitude', Number.MAX_SAFE_INTEGER],
16+
])('rejects a %s before constructing a customer payment', (_name, totalAmount) => {
17+
expect(() =>
18+
buildQuickBooksCreatePaymentBody({
19+
accessToken: 'token',
20+
realmId: 'realm',
21+
customerId: 'customer-1',
22+
totalAmount: totalAmount as number,
23+
})
24+
).toThrow()
25+
})
26+
27+
it('preserves valid positive payment amounts and negative sales amounts', () => {
28+
expect(
29+
buildQuickBooksCreatePaymentBody({
30+
accessToken: 'token',
31+
realmId: 'realm',
32+
customerId: 'customer-1',
33+
totalAmount: 10.25,
34+
invoiceAllocations: [{ invoiceId: 'invoice-1', amount: 10.25 }],
35+
})
36+
).toMatchObject({
37+
TotalAmt: 10.25,
38+
Line: [
39+
{
40+
Amount: 10.25,
41+
LinkedTxn: [{ TxnId: 'invoice-1', TxnType: 'Invoice' }],
42+
},
43+
],
44+
})
45+
46+
expect(
47+
parseQuickBooksSalesLines([
48+
{
49+
lineType: 'item',
50+
amount: -10.25,
51+
itemId: 'item-1',
52+
quantity: 1,
53+
unitPrice: -10.25,
54+
},
55+
])
56+
).toEqual([
57+
{
58+
lineType: 'item',
59+
amount: -10.25,
60+
itemId: 'item-1',
61+
description: undefined,
62+
quantity: 1,
63+
unitPrice: -10.25,
64+
serviceDate: undefined,
65+
},
66+
])
67+
})
68+
69+
it('accepts trimmed numeric strings without changing their numeric payload values', () => {
70+
expect(
71+
buildQuickBooksCreatePaymentBody({
72+
accessToken: 'token',
73+
realmId: 'realm',
74+
customerId: 'customer-1',
75+
totalAmount: ' 10.25 ' as unknown as number,
76+
invoiceAllocations: [{ invoiceId: 'invoice-1', amount: ' 10.25 ' as unknown as number }],
77+
})
78+
).toMatchObject({ TotalAmt: 10.25, Line: [{ Amount: 10.25 }] })
79+
})
80+
})
81+
82+
describe('QuickBooks customer payment allocations', () => {
83+
const duplicates = [
84+
{ invoiceId: ' invoice-1 ', amount: 5 },
85+
{ invoiceId: 'invoice-1', amount: 5 },
86+
]
87+
88+
afterEach(() => {
89+
vi.unstubAllGlobals()
90+
})
91+
92+
it('rejects duplicate trimmed invoice IDs during parsing', () => {
93+
expect(() => parseQuickBooksInvoiceAllocations(duplicates)).toThrow(
94+
'invoiceAllocations lists invoice invoice-1 more than once'
95+
)
96+
})
97+
98+
it('rejects duplicate invoice IDs for both create and update bodies', () => {
99+
expect(() =>
100+
buildQuickBooksCreatePaymentBody({
101+
accessToken: 'token',
102+
realmId: 'realm',
103+
customerId: 'customer-1',
104+
totalAmount: 10,
105+
invoiceAllocations: duplicates,
106+
})
107+
).toThrow('invoiceAllocations lists invoice invoice-1 more than once')
108+
109+
expect(() =>
110+
buildQuickBooksUpdatePaymentBody({
111+
accessToken: 'token',
112+
realmId: 'realm',
113+
paymentId: 'payment-1',
114+
syncToken: '0',
115+
totalAmount: 10,
116+
invoiceAllocations: duplicates,
117+
unapplyOmittedInvoices: true,
118+
})
119+
).toThrow('invoiceAllocations lists invoice invoice-1 more than once')
120+
})
121+
122+
it('rejects duplicate invoice IDs before the Update Payment preservation read', async () => {
123+
const fetchMock = vi.fn()
124+
vi.stubGlobal('fetch', fetchMock)
125+
126+
await expect(
127+
quickbooksUpdateCustomerPaymentTool.directExecution?.(
128+
{
129+
accessToken: 'token',
130+
realmId: 'realm',
131+
paymentId: 'payment-1',
132+
syncToken: '0',
133+
invoiceAllocations: duplicates,
134+
},
135+
undefined
136+
)
137+
).rejects.toThrow('invoiceAllocations lists invoice invoice-1 more than once')
138+
expect(fetchMock).not.toHaveBeenCalled()
139+
})
140+
})

apps/sim/tools/quickbooks/sales_utils.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,49 @@ function assertAllowedKeys(
6262
if (unknownKey) throw new Error(`${fieldName} contains unsupported field "${unknownKey}"`)
6363
}
6464

65-
function requiredPositiveNumber(value: unknown, fieldName: string): number {
66-
const parsed = typeof value === 'number' ? value : Number(value)
67-
if (!Number.isFinite(parsed) || parsed <= 0) {
68-
throw new Error(`${fieldName} must be a positive finite number`)
65+
function quickBooksMoneyDecimal(value: unknown, fieldName: string, requirement: string): Decimal {
66+
if (typeof value !== 'number' && typeof value !== 'string') {
67+
throw new Error(`${fieldName} must be a ${requirement}`)
6968
}
70-
return parsed
69+
const normalized = typeof value === 'string' ? value.trim() : value
70+
if (normalized === '') throw new Error(`${fieldName} must be a ${requirement}`)
71+
72+
let decimal: Decimal
73+
try {
74+
decimal = new Decimal(normalized)
75+
} catch {
76+
throw new Error(`${fieldName} must be a ${requirement}`)
77+
}
78+
if (!decimal.isFinite()) throw new Error(`${fieldName} must be a ${requirement}`)
79+
if (decimal.decimalPlaces() > 2) {
80+
throw new Error(`${fieldName} cannot have more than two decimal places`)
81+
}
82+
83+
const number = decimal.toNumber()
84+
if (
85+
!Number.isSafeInteger(decimal.times(100).toNumber()) ||
86+
!Number.isFinite(number) ||
87+
!new Decimal(number).equals(decimal)
88+
) {
89+
throw new Error(`${fieldName} is outside the safely supported amount range`)
90+
}
91+
return decimal
92+
}
93+
94+
function requiredPositiveNumber(value: unknown, fieldName: string): number {
95+
const decimal = quickBooksMoneyDecimal(value, fieldName, 'positive finite number')
96+
if (decimal.lte(0)) throw new Error(`${fieldName} must be a positive finite number`)
97+
return decimal.toNumber()
7198
}
7299

73100
function optionalPositiveNumber(value: unknown, fieldName: string): number | undefined {
74101
if (value == null || value === '') return undefined
75-
const parsed = typeof value === 'number' ? value : Number(value)
102+
if (typeof value !== 'number' && typeof value !== 'string') {
103+
throw new Error(`${fieldName} must be a positive finite number`)
104+
}
105+
const normalized = typeof value === 'string' ? value.trim() : value
106+
if (normalized === '') return undefined
107+
const parsed = typeof normalized === 'number' ? normalized : Number(normalized)
76108
if (!Number.isFinite(parsed) || parsed <= 0) {
77109
throw new Error(`${fieldName} must be a positive finite number`)
78110
}
@@ -86,11 +118,9 @@ function optionalPositiveNumber(value: unknown, fieldName: string): number | und
86118
* a sales form. Only a zero or non-finite value is rejected.
87119
*/
88120
function requiredNonZeroNumber(value: unknown, fieldName: string): number {
89-
const parsed = typeof value === 'number' ? value : Number(value)
90-
if (!Number.isFinite(parsed) || parsed === 0) {
91-
throw new Error(`${fieldName} must be a non-zero finite number`)
92-
}
93-
return parsed
121+
const decimal = quickBooksMoneyDecimal(value, fieldName, 'non-zero finite number')
122+
if (decimal.isZero()) throw new Error(`${fieldName} must be a non-zero finite number`)
123+
return decimal.toNumber()
94124
}
95125

96126
function optionalNonZeroNumber(value: unknown, fieldName: string): number | undefined {
@@ -195,12 +225,18 @@ export function parseQuickBooksInvoiceAllocations(
195225
if (parsed.length > MAX_PAYMENT_ALLOCATIONS) {
196226
throw new Error(`${fieldName} cannot contain more than ${MAX_PAYMENT_ALLOCATIONS} allocations`)
197227
}
228+
const invoiceIds = new Set<string>()
198229
return parsed.map((rawAllocation, index) => {
199230
const itemName = `${fieldName}[${index}]`
200231
const allocation = assertObject(rawAllocation, itemName)
201232
assertAllowedKeys(allocation, PAYMENT_ALLOCATION_KEYS, itemName)
233+
const invoiceId = requiredStringValue(allocation.invoiceId, `${itemName}.invoiceId`)
234+
if (invoiceIds.has(invoiceId)) {
235+
throw new Error(`${fieldName} lists invoice ${invoiceId} more than once`)
236+
}
237+
invoiceIds.add(invoiceId)
202238
return {
203-
invoiceId: requiredStringValue(allocation.invoiceId, `${itemName}.invoiceId`),
239+
invoiceId,
204240
amount: requiredPositiveNumber(allocation.amount, `${itemName}.amount`),
205241
}
206242
})

0 commit comments

Comments
 (0)