Skip to content

Commit 171c306

Browse files
waleedlatif1claude
andcommitted
fix(quickbooks): document-align the bill payment account check and refund receipt update
Intuit's BillPaymentCheck.BankAccountRef requires "Account.AccountType set to Bank and Account.AccountSubType set to Checking", and BillPaymentCreditCard.CCAccountRef requires "AccountType set to Credit Card and AccountSubType set to CreditCard". The create-bill-payment guard only compared AccountType, so a Savings or Line-of-Credit account passed the local check and failed at Intuit. Intuit documents RefundReceipt::UPDATE "Sparse update a refund receipt", which "only elements specified in the request are updated. Missing elements are left untouched." The operation used the read-merge-write full update instead, adding a round trip and a read/write race. Invoice, Estimate and SalesReceipt already post their sparse bodies directly; RefundReceipt now matches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB
1 parent 6e1c7d1 commit 171c306

3 files changed

Lines changed: 185 additions & 57 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/core/config/env', () => ({
7+
env: { QUICKBOOKS_ENV: 'production' },
8+
}))
9+
10+
import {
11+
executeQuickBooksCreateBillPaymentOperation,
12+
executeQuickBooksUpdateRefundReceiptOperation,
13+
} from '@/lib/internal/quickbooks/provider-operations'
14+
15+
const AUTH = {
16+
accessToken: 'token',
17+
realmId: '123',
18+
quickBooksEnvironment: 'sandbox',
19+
} as const
20+
21+
function billPaymentParams(paymentType: 'check' | 'credit_card') {
22+
return {
23+
...AUTH,
24+
vendorId: 'vendor-1',
25+
paymentType,
26+
paymentAccountId: 'account-1',
27+
billAllocations: [{ billId: 'bill-1', amount: 10 }],
28+
totalAmount: 10,
29+
}
30+
}
31+
32+
describe('QuickBooks bill payment account compatibility', () => {
33+
beforeEach(() => {
34+
vi.stubGlobal('fetch', vi.fn())
35+
})
36+
37+
afterEach(() => {
38+
vi.unstubAllGlobals()
39+
})
40+
41+
it('refuses a Bank account whose sub-type is not the documented Checking', async () => {
42+
vi.mocked(fetch).mockResolvedValueOnce(
43+
Response.json({
44+
Account: {
45+
Id: 'account-1',
46+
SyncToken: '0',
47+
AccountType: 'Bank',
48+
AccountSubType: 'Savings',
49+
},
50+
})
51+
)
52+
53+
await expect(
54+
executeQuickBooksCreateBillPaymentOperation(billPaymentParams('check'))
55+
).rejects.toThrow('Checking sub-type')
56+
expect(fetch).toHaveBeenCalledOnce()
57+
})
58+
59+
it('refuses a Credit Card account whose sub-type is not the documented CreditCard', async () => {
60+
vi.mocked(fetch).mockResolvedValueOnce(
61+
Response.json({
62+
Account: {
63+
Id: 'account-1',
64+
SyncToken: '0',
65+
AccountType: 'Credit Card',
66+
AccountSubType: 'LineOfCredit',
67+
},
68+
})
69+
)
70+
71+
await expect(
72+
executeQuickBooksCreateBillPaymentOperation(billPaymentParams('credit_card'))
73+
).rejects.toThrow('CreditCard sub-type')
74+
expect(fetch).toHaveBeenCalledOnce()
75+
})
76+
77+
it('accepts the documented Bank/Checking pair', async () => {
78+
vi.mocked(fetch)
79+
.mockResolvedValueOnce(
80+
Response.json({
81+
Account: {
82+
Id: 'account-1',
83+
SyncToken: '0',
84+
AccountType: 'Bank',
85+
AccountSubType: 'Checking',
86+
},
87+
})
88+
)
89+
.mockResolvedValueOnce(Response.json({ BillPayment: { Id: 'pay-1', SyncToken: '0' } }))
90+
91+
const result = await executeQuickBooksCreateBillPaymentOperation(billPaymentParams('check'))
92+
expect(result.output.recordId).toBe('pay-1')
93+
expect(fetch).toHaveBeenCalledTimes(2)
94+
})
95+
})
96+
97+
describe('QuickBooks refund receipt sparse update', () => {
98+
beforeEach(() => {
99+
vi.stubGlobal('fetch', vi.fn())
100+
})
101+
102+
afterEach(() => {
103+
vi.unstubAllGlobals()
104+
})
105+
106+
it('posts the documented sparse body without reading the record first', async () => {
107+
vi.mocked(fetch).mockResolvedValueOnce(
108+
Response.json({ RefundReceipt: { Id: 'refund-1', SyncToken: '3' } })
109+
)
110+
111+
const result = await executeQuickBooksUpdateRefundReceiptOperation({
112+
...AUTH,
113+
transactionId: 'refund-1',
114+
syncToken: '2',
115+
lines: [{ lineType: 'item', amount: 10, itemId: 'item-1' }],
116+
})
117+
118+
expect(fetch).toHaveBeenCalledOnce()
119+
const [url, init] = vi.mocked(fetch).mock.calls[0] ?? []
120+
expect(String(url)).toContain('/refundreceipt')
121+
expect(String(init?.method)).toBe('POST')
122+
expect(JSON.parse(String(init?.body))).toEqual({
123+
Id: 'refund-1',
124+
SyncToken: '2',
125+
sparse: true,
126+
Line: [
127+
{
128+
Amount: 10,
129+
DetailType: 'SalesItemLineDetail',
130+
SalesItemLineDetail: { ItemRef: { value: 'item-1' } },
131+
},
132+
],
133+
})
134+
expect(result.output.syncToken).toBe('3')
135+
})
136+
})

‎apps/sim/lib/internal/quickbooks/provider-operations.ts‎

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ import {
5555
validateQuickBooksOptionalNumber,
5656
} from '@/tools/quickbooks/values'
5757

58+
/**
59+
* Intuit constrains the BillPayment payment account by both classification
60+
* fields, not by `AccountType` alone. `BillPaymentCheck.BankAccountRef`: "The
61+
* specified account must have `Account.AccountType` set to `Bank` and
62+
* `Account.AccountSubType` set to `Checking`."
63+
* `BillPaymentCreditCard.CCAccountRef`: "The specified account must have
64+
* `Account.AccountType` set to `Credit Card` and `Account.AccountSubType` set
65+
* to `CreditCard`."
66+
*/
67+
const QUICKBOOKS_BILL_PAYMENT_ACCOUNTS = {
68+
check: { label: 'Check', accountType: 'Bank', accountSubType: 'Checking' },
69+
credit_card: { label: 'Credit-card', accountType: 'Credit Card', accountSubType: 'CreditCard' },
70+
} as const satisfies Record<
71+
QuickBooksCreateBillPaymentParams['paymentType'],
72+
{ label: string; accountType: string; accountSubType: string }
73+
>
74+
5875
function assertCompatiblePaymentAccount(
5976
account: QuickBooksAccount,
6077
paymentType: QuickBooksCreateBillPaymentParams['paymentType'],
@@ -68,10 +85,18 @@ function assertCompatiblePaymentAccount(
6885
throw new Error('QuickBooks payment account is inactive. Select an active account.')
6986
}
7087

71-
const expectedAccountType = paymentType === 'check' ? 'Bank' : 'Credit Card'
72-
if (account.AccountType !== expectedAccountType) {
88+
const expected = QUICKBOOKS_BILL_PAYMENT_ACCOUNTS[paymentType]
89+
if (!expected) {
90+
throw new Error(`Unsupported QuickBooks bill payment type: ${String(paymentType)}`)
91+
}
92+
if (account.AccountType !== expected.accountType) {
93+
throw new Error(
94+
`${expected.label} Bill Payments require a QuickBooks ${expected.accountType} account. Account ${paymentAccountId} is ${account.AccountType || 'missing an account type'}.`
95+
)
96+
}
97+
if (account.AccountSubType !== expected.accountSubType) {
7398
throw new Error(
74-
`${paymentType === 'check' ? 'Check' : 'Credit-card'} Bill Payments require a QuickBooks ${expectedAccountType} account. Account ${paymentAccountId} is ${account.AccountType || 'missing an account type'}.`
99+
`${expected.label} Bill Payments require a QuickBooks ${expected.accountType} account with the ${expected.accountSubType} sub-type. Account ${paymentAccountId} is ${account.AccountSubType || 'missing an account sub-type'}.`
75100
)
76101
}
77102
}
@@ -237,19 +262,32 @@ export function executeQuickBooksUpdateCreditMemoOperation(
237262
})
238263
}
239264

240-
export function executeQuickBooksUpdateRefundReceiptOperation(
265+
/**
266+
* Intuit documents `RefundReceipt::UPDATE "Sparse update a refund receipt"`:
267+
* "Sparse updating provides the ability to update a subset of properties for a
268+
* given object; only elements specified in the request are updated. Missing
269+
* elements are left untouched." The sparse operation is posted directly, so no
270+
* read-merge-write round trip is needed to preserve untouched fields.
271+
*/
272+
export async function executeQuickBooksUpdateRefundReceiptOperation(
241273
params: QuickBooksUpdateRefundReceiptParams,
242274
signal?: AbortSignal
243275
) {
244-
return executeQuickBooksFullUpdate({
245-
params,
276+
const response = await fetch(buildQuickBooksEntityUrl(params, 'refundreceipt'), {
277+
method: 'POST',
278+
headers: getQuickBooksToolHeaders(params.accessToken, 'application/json'),
279+
body: JSON.stringify(buildQuickBooksUpdateSalesDocumentBody(params)),
246280
signal,
247-
entity: 'RefundReceipt',
248-
resource: 'refundreceipt',
249-
recordId: params.transactionId,
250-
syncToken: params.syncToken,
251-
buildPatch: buildQuickBooksUpdateSalesDocumentBody,
252281
})
282+
if (!response.ok) {
283+
throw await getQuickBooksOperationError(response, 'RefundReceipt', signal)
284+
}
285+
return transformQuickBooksMutationResponse<QuickBooksSalesTransaction>(
286+
response,
287+
'RefundReceipt',
288+
undefined,
289+
signal
290+
)
253291
}
254292

255293
/** Preserves QuickBooks' all-or-none Payment lines across a full update. */

‎apps/sim/tools/quickbooks/full_update.test.ts‎

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55
import {
66
executeQuickBooksUpdateItemOperation,
7-
executeQuickBooksUpdateRefundReceiptOperation,
87
executeQuickBooksUpdateVendorOperation,
98
} from '@/lib/internal/quickbooks/provider-operations'
109

@@ -108,51 +107,6 @@ describe('QuickBooks documented full updates', () => {
108107
expect(fetch).toHaveBeenCalledOnce()
109108
})
110109

111-
it('replaces refund-receipt lines only after reading and preserving the complete entity', async () => {
112-
vi.mocked(fetch)
113-
.mockResolvedValueOnce(
114-
Response.json({
115-
RefundReceipt: {
116-
Id: 'refund-1',
117-
SyncToken: '2',
118-
CustomerRef: { value: 'customer-1' },
119-
DepositToAccountRef: { value: 'account-1' },
120-
Line: [{ Id: 'old-line', Amount: 25 }],
121-
PrivateNote: 'keep me',
122-
MetaData: { CreateTime: '2026-08-01T00:00:00Z' },
123-
domain: 'QBO',
124-
sparse: false,
125-
},
126-
})
127-
)
128-
.mockResolvedValueOnce(Response.json({ RefundReceipt: { Id: 'refund-1', SyncToken: '3' } }))
129-
130-
await executeQuickBooksUpdateRefundReceiptOperation({
131-
accessToken: 'token',
132-
realmId: '123',
133-
quickBooksEnvironment: 'sandbox',
134-
transactionId: 'refund-1',
135-
syncToken: '2',
136-
lines: [{ lineType: 'item', amount: 10, itemId: 'item-1' }],
137-
})
138-
139-
expect(fetch).toHaveBeenCalledTimes(2)
140-
expect(JSON.parse(String(vi.mocked(fetch).mock.calls[1]?.[1]?.body))).toEqual({
141-
Id: 'refund-1',
142-
SyncToken: '2',
143-
CustomerRef: { value: 'customer-1' },
144-
DepositToAccountRef: { value: 'account-1' },
145-
Line: [
146-
{
147-
Amount: 10,
148-
DetailType: 'SalesItemLineDetail',
149-
SalesItemLineDetail: { ItemRef: { value: 'item-1' } },
150-
},
151-
],
152-
PrivateNote: 'keep me',
153-
})
154-
})
155-
156110
it('merges the unsanitized vendor record so a secret-stripped field survives the update', async () => {
157111
vi.mocked(fetch)
158112
.mockResolvedValueOnce(

0 commit comments

Comments
 (0)