Skip to content

Commit ff29690

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): validate bill payment accounts
1 parent 7ea91de commit ff29690

3 files changed

Lines changed: 247 additions & 8 deletions

File tree

apps/sim/tools/quickbooks/create_bill_payment.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { ErrorExtractorId } from '@/tools/error-extractors'
1+
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
2+
import { ErrorExtractorId, extractErrorMessage } from '@/tools/error-extractors'
23
import { QUICKBOOKS_MAX_RESPONSE_BYTES } from '@/tools/quickbooks/client'
34
import { buildQuickBooksCreateBillPaymentBody } from '@/tools/quickbooks/purchasing_utils'
45
import type {
6+
QuickBooksAccount,
57
QuickBooksCreateBillPaymentParams,
68
QuickBooksMutationResponse,
79
QuickBooksPurchasingTransaction,
@@ -14,10 +16,59 @@ import {
1416
addQuickBooksRequestId,
1517
buildQuickBooksEntityUrl,
1618
getQuickBooksToolHeaders,
19+
transformQuickBooksEntityResponse,
1720
transformQuickBooksMutationResponse,
1821
} from '@/tools/quickbooks/utils'
1922
import type { ToolConfig } from '@/tools/types'
2023

24+
async function getQuickBooksDirectExecutionError(
25+
response: Response,
26+
signal?: AbortSignal
27+
): Promise<Error> {
28+
let data: unknown = null
29+
try {
30+
data = await readResponseJsonWithLimit<unknown>(response, {
31+
maxBytes: QUICKBOOKS_MAX_RESPONSE_BYTES,
32+
label: 'QuickBooks BillPayment error response',
33+
signal,
34+
})
35+
} catch {
36+
signal?.throwIfAborted()
37+
}
38+
39+
const errorInfo = {
40+
status: response.status,
41+
statusText: response.statusText,
42+
data,
43+
headers: response.headers,
44+
}
45+
return Object.assign(
46+
new Error(extractErrorMessage(errorInfo, ErrorExtractorId.QUICKBOOKS_FAULT)),
47+
errorInfo
48+
)
49+
}
50+
51+
function assertCompatiblePaymentAccount(
52+
account: QuickBooksAccount,
53+
paymentType: QuickBooksCreateBillPaymentParams['paymentType'],
54+
paymentAccountId: string
55+
): void {
56+
const accountId = account.Id.trim()
57+
if (accountId !== paymentAccountId) {
58+
throw new Error('QuickBooks returned a different payment account than requested')
59+
}
60+
if (account.Active === false) {
61+
throw new Error('QuickBooks payment account is inactive. Select an active account.')
62+
}
63+
64+
const expectedAccountType = paymentType === 'check' ? 'Bank' : 'Credit Card'
65+
if (account.AccountType !== expectedAccountType) {
66+
throw new Error(
67+
`${paymentType === 'check' ? 'Check' : 'Credit-card'} Bill Payments require a QuickBooks ${expectedAccountType} account. Account ${paymentAccountId} is ${account.AccountType || 'missing an account type'}.`
68+
)
69+
}
70+
}
71+
2172
export const quickbooksCreateBillPaymentTool: ToolConfig<
2273
QuickBooksCreateBillPaymentParams,
2374
QuickBooksMutationResponse<QuickBooksPurchasingTransaction>
@@ -106,6 +157,51 @@ export const quickbooksCreateBillPaymentTool: ToolConfig<
106157
retry: { enabled: false },
107158
maxResponseBytes: QUICKBOOKS_MAX_RESPONSE_BYTES,
108159
},
160+
directExecution: async (params, signal) => {
161+
const paymentAccountId = params.paymentAccountId.trim()
162+
if (!paymentAccountId) throw new Error('paymentAccountId is required')
163+
164+
const accountResponse = await fetch(
165+
buildQuickBooksEntityUrl(params.realmId, 'account', paymentAccountId),
166+
{
167+
method: 'GET',
168+
headers: getQuickBooksToolHeaders(params.accessToken),
169+
signal,
170+
}
171+
)
172+
if (!accountResponse.ok) {
173+
throw await getQuickBooksDirectExecutionError(accountResponse, signal)
174+
}
175+
const { item: account } = await transformQuickBooksEntityResponse<QuickBooksAccount>(
176+
accountResponse,
177+
'Account',
178+
signal
179+
)
180+
assertCompatiblePaymentAccount(account, params.paymentType, paymentAccountId)
181+
signal?.throwIfAborted()
182+
183+
const paymentResponse = await fetch(
184+
addQuickBooksRequestId(
185+
buildQuickBooksEntityUrl(params.realmId, 'billpayment'),
186+
params.requestId
187+
),
188+
{
189+
method: 'POST',
190+
headers: getQuickBooksToolHeaders(params.accessToken, 'application/json'),
191+
body: JSON.stringify(buildQuickBooksCreateBillPaymentBody(params)),
192+
signal,
193+
}
194+
)
195+
if (!paymentResponse.ok) {
196+
throw await getQuickBooksDirectExecutionError(paymentResponse, signal)
197+
}
198+
return transformQuickBooksMutationResponse<QuickBooksPurchasingTransaction>(
199+
paymentResponse,
200+
'BillPayment',
201+
undefined,
202+
signal
203+
)
204+
},
109205
transformResponse: (r) =>
110206
transformQuickBooksMutationResponse<QuickBooksPurchasingTransaction>(r, 'BillPayment'),
111207
outputs: {

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

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { resetEnvMock, setEnv } from '@sim/testing'
2-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33
import { QuickBooksBlock } from '@/blocks/blocks/quickbooks'
44
import {
55
quickbooksCreateBillPaymentTool,
@@ -52,7 +52,10 @@ const itemLine = {
5252
}
5353

5454
beforeEach(() => setEnv({ QUICKBOOKS_ENV: 'sandbox' }))
55-
afterEach(resetEnvMock)
55+
afterEach(() => {
56+
vi.unstubAllGlobals()
57+
resetEnvMock()
58+
})
5659

5760
describe('QuickBooks purchasing reader', () => {
5861
const listParams: QuickBooksReadPurchasingTransactionsParams = {
@@ -447,6 +450,135 @@ describe('QuickBooks purchasing mutation bodies', () => {
447450
})
448451
})
449452

453+
describe('QuickBooks BillPayment account compatibility', () => {
454+
const params: QuickBooksCreateBillPaymentParams = {
455+
...authParams,
456+
vendorId: '30',
457+
totalAmount: 25,
458+
paymentType: 'check',
459+
paymentAccountId: '35',
460+
billAllocations: [{ billId: '12', amount: 25 }],
461+
requestId: 'sanitized-request-id',
462+
}
463+
464+
function accountResponse(account: Record<string, unknown>): Response {
465+
return Response.json({ Account: account, time: 'test-time' })
466+
}
467+
468+
function billPaymentResponse(payType: 'Check' | 'CreditCard'): Response {
469+
return Response.json({
470+
BillPayment: { Id: '44', SyncToken: '0', PayType: payType },
471+
time: 'test-time',
472+
})
473+
}
474+
475+
it.each([
476+
['check', 'Bank', 'Check'],
477+
['credit_card', 'Credit Card', 'CreditCard'],
478+
] as const)(
479+
'validates the account before creating a %s payment',
480+
async (paymentType, accountType, payType) => {
481+
const fetchMock = vi
482+
.fn()
483+
.mockResolvedValueOnce(
484+
accountResponse({ Id: '35', SyncToken: '0', Active: true, AccountType: accountType })
485+
)
486+
.mockResolvedValueOnce(billPaymentResponse(payType))
487+
vi.stubGlobal('fetch', fetchMock)
488+
489+
await expect(
490+
quickbooksCreateBillPaymentTool.directExecution!({ ...params, paymentType })
491+
).resolves.toMatchObject({
492+
success: true,
493+
output: { recordId: '44', record: { PayType: payType } },
494+
})
495+
496+
expect(fetchMock).toHaveBeenCalledTimes(2)
497+
expect(new URL(fetchMock.mock.calls[0][0] as URL).pathname).toBe(
498+
'/v3/company/123456789/account/35'
499+
)
500+
const mutationUrl = new URL(fetchMock.mock.calls[1][0] as URL)
501+
expect(mutationUrl.pathname).toBe('/v3/company/123456789/billpayment')
502+
expect(mutationUrl.searchParams.get('requestid')).toBe('sanitized-request-id')
503+
expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toMatchObject({
504+
PayType: payType,
505+
})
506+
}
507+
)
508+
509+
it.each([
510+
['check', 'Credit Card', 'Bank'],
511+
['credit_card', 'Bank', 'Credit Card'],
512+
] as const)(
513+
'rejects a %s payment when the account is %s without mutating',
514+
async (paymentType, accountType, expectedType) => {
515+
const fetchMock = vi
516+
.fn()
517+
.mockResolvedValue(
518+
accountResponse({ Id: '35', SyncToken: '0', Active: true, AccountType: accountType })
519+
)
520+
vi.stubGlobal('fetch', fetchMock)
521+
522+
await expect(
523+
quickbooksCreateBillPaymentTool.directExecution!({ ...params, paymentType })
524+
).rejects.toThrow(`require a QuickBooks ${expectedType} account`)
525+
expect(fetchMock).toHaveBeenCalledTimes(1)
526+
}
527+
)
528+
529+
it('rejects inactive and mismatched account records without mutating', async () => {
530+
const fetchMock = vi
531+
.fn()
532+
.mockResolvedValueOnce(
533+
accountResponse({ Id: '35', SyncToken: '0', Active: false, AccountType: 'Bank' })
534+
)
535+
.mockResolvedValueOnce(
536+
accountResponse({ Id: '99', SyncToken: '0', Active: true, AccountType: 'Bank' })
537+
)
538+
vi.stubGlobal('fetch', fetchMock)
539+
540+
await expect(quickbooksCreateBillPaymentTool.directExecution!(params)).rejects.toThrow(
541+
'payment account is inactive'
542+
)
543+
await expect(quickbooksCreateBillPaymentTool.directExecution!(params)).rejects.toThrow(
544+
'different payment account'
545+
)
546+
expect(fetchMock).toHaveBeenCalledTimes(2)
547+
})
548+
549+
it('preserves bounded QuickBooks fault guidance from the account preflight', async () => {
550+
vi.stubGlobal(
551+
'fetch',
552+
vi
553+
.fn()
554+
.mockResolvedValue(
555+
Response.json(
556+
{ Fault: { Error: [{ code: '3200', Message: 'Authentication failed' }] } },
557+
{ status: 401, headers: { intuit_tid: 'tracking-id' } }
558+
)
559+
)
560+
)
561+
562+
await expect(quickbooksCreateBillPaymentTool.directExecution!(params)).rejects.toThrow(
563+
'Reconnect the QuickBooks credential'
564+
)
565+
})
566+
567+
it('propagates cancellation and does not create a payment', async () => {
568+
const controller = new AbortController()
569+
const fetchMock = vi.fn().mockImplementationOnce(() => {
570+
controller.abort(new Error('cancelled'))
571+
return accountResponse({ Id: '35', SyncToken: '0', Active: true, AccountType: 'Bank' })
572+
})
573+
vi.stubGlobal('fetch', fetchMock)
574+
575+
await expect(
576+
quickbooksCreateBillPaymentTool.directExecution!(params, controller.signal)
577+
).rejects.toThrow('cancelled')
578+
expect(fetchMock).toHaveBeenCalledTimes(1)
579+
})
580+
})
581+
450582
describe('QuickBooks purchasing block', () => {
451583
it('does not force array-valued wand prompts through JSON-object generation', () => {
452584
for (const id of ['purchasingLines', 'billAllocations']) {

apps/sim/tools/quickbooks/utils.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,18 @@ export function addQuickBooksRequestId(url: URL, requestId?: string): URL {
176176
return url
177177
}
178178

179-
export async function parseQuickBooksJson<T>(response: Response, label: string): Promise<T> {
179+
export async function parseQuickBooksJson<T>(
180+
response: Response,
181+
label: string,
182+
signal?: AbortSignal
183+
): Promise<T> {
180184
if (!response.ok) {
181185
throw new Error(`QuickBooks request failed with HTTP ${response.status}`)
182186
}
183187
const data = await readResponseJsonWithLimit<T>(response, {
184188
maxBytes: QUICKBOOKS_MAX_RESPONSE_BYTES,
185189
label,
190+
signal,
186191
})
187192
const faultData = sanitizeQuickBooksFaultData(data)
188193
if (faultData) {
@@ -242,10 +247,15 @@ export async function transformQuickBooksListResponse<T>(
242247

243248
export async function transformQuickBooksEntityResponse<
244249
T extends { Id: string; SyncToken?: string },
245-
>(response: Response, entity: QuickBooksQueryEntity): Promise<{ item: T; time: string | null }> {
250+
>(
251+
response: Response,
252+
entity: QuickBooksQueryEntity,
253+
signal?: AbortSignal
254+
): Promise<{ item: T; time: string | null }> {
246255
const data = await parseQuickBooksJson<Record<string, unknown> & { time?: string }>(
247256
response,
248-
`QuickBooks ${entity} response`
257+
`QuickBooks ${entity} response`,
258+
signal
249259
)
250260
const candidate = data[entity]
251261
if (!candidate) {
@@ -262,9 +272,10 @@ export async function transformQuickBooksMutationResponse<
262272
>(
263273
response: Response,
264274
entity: QuickBooksQueryEntity,
265-
sanitize: (item: T) => T = (item) => item
275+
sanitize: (item: T) => T = (item) => item,
276+
signal?: AbortSignal
266277
): Promise<QuickBooksMutationResponse<T>> {
267-
const parsed = await transformQuickBooksEntityResponse<T>(response, entity)
278+
const parsed = await transformQuickBooksEntityResponse<T>(response, entity, signal)
268279
const item = sanitize(parsed.item)
269280
const recordId = typeof item.Id === 'string' ? item.Id.trim() : ''
270281
const syncToken = typeof item.SyncToken === 'string' ? item.SyncToken.trim() : ''

0 commit comments

Comments
 (0)