Skip to content

Commit f85a8e0

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(quickbooks): add safe n8n parity tools
1 parent af5a745 commit f85a8e0

17 files changed

Lines changed: 1135 additions & 136 deletions

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ beforeEach(() => setEnv({ QUICKBOOKS_ENV: 'sandbox' }))
3333
afterEach(resetEnvMock)
3434

3535
describe('QuickBooks accounting reader', () => {
36+
it('builds a bounded transaction-date range', () => {
37+
const requestUrl = quickbooksReadAccountingTransactionsTool.request.url as (
38+
params: QuickBooksReadAccountingTransactionsParams
39+
) => string
40+
const url = new URL(
41+
requestUrl({
42+
...authParams,
43+
transactionType: 'journal_entry',
44+
readMode: 'list',
45+
startDate: '2026-01-01',
46+
endDate: '2026-01-31',
47+
startPosition: 1,
48+
maxResults: 25,
49+
})
50+
)
51+
expect(url.searchParams.get('query')).toContain(
52+
"WHERE TxnDate >= '2026-01-01' AND TxnDate <= '2026-01-31'"
53+
)
54+
})
3655
const listParams: QuickBooksReadAccountingTransactionsParams = {
3756
...authParams,
3857
transactionType: 'journal_entry',
@@ -351,11 +370,11 @@ describe('QuickBooks accounting block', () => {
351370
})
352371
})
353372

354-
it('exposes exactly 45 operations with tool/access parity', () => {
373+
it('exposes exactly 47 operations with tool/access parity', () => {
355374
const operation = QuickBooksBlock.subBlocks.find((subBlock) => subBlock.id === 'operation')
356375
const operationIds = (operation?.options ?? []).map((option) => option.id)
357-
expect(operationIds).toHaveLength(45)
358-
expect(new Set(operationIds).size).toBe(45)
376+
expect(operationIds).toHaveLength(47)
377+
expect(new Set(operationIds).size).toBe(47)
359378
expect(operationIds).toEqual(QuickBooksBlock.tools.access)
360379
})
361380

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { filterUndefined } from '@sim/utils/object'
2+
import { ErrorExtractorId } from '@/tools/error-extractors'
3+
import { QUICKBOOKS_MAX_RESPONSE_BYTES } from '@/tools/quickbooks/client'
4+
import type {
5+
QuickBooksCreateEmployeeParams,
6+
QuickBooksEmployee,
7+
QuickBooksMutationResponse,
8+
} from '@/tools/quickbooks/types'
9+
import {
10+
QUICKBOOKS_EMPLOYEE_PROPERTIES,
11+
QUICKBOOKS_MUTATION_OUTPUTS,
12+
} from '@/tools/quickbooks/types'
13+
import {
14+
addQuickBooksRequestId,
15+
buildQuickBooksEntityUrl,
16+
getQuickBooksToolHeaders,
17+
optionalQuickBooksString,
18+
parseQuickBooksAddress,
19+
quickBooksEmailAddress,
20+
quickBooksPhoneNumber,
21+
requiredQuickBooksString,
22+
sanitizeQuickBooksEmployee,
23+
transformQuickBooksMutationResponse,
24+
} from '@/tools/quickbooks/utils'
25+
import type { ToolConfig } from '@/tools/types'
26+
27+
export const quickbooksCreateEmployeeTool: ToolConfig<
28+
QuickBooksCreateEmployeeParams,
29+
QuickBooksMutationResponse<QuickBooksEmployee>
30+
> = {
31+
id: 'quickbooks_create_employee',
32+
name: 'QuickBooks Create Employee',
33+
description: 'Create a non-payroll employee profile in the connected QuickBooks Online company',
34+
version: '1.0.0',
35+
params: {
36+
accessToken: {
37+
type: 'string',
38+
required: true,
39+
visibility: 'hidden',
40+
description: 'QuickBooks OAuth access token',
41+
},
42+
realmId: {
43+
type: 'string',
44+
required: true,
45+
visibility: 'hidden',
46+
description: 'QuickBooks company ID derived from the connected credential',
47+
},
48+
displayName: {
49+
type: 'string',
50+
required: true,
51+
visibility: 'user-or-llm',
52+
description: 'Unique employee display name',
53+
},
54+
givenName: {
55+
type: 'string',
56+
required: false,
57+
visibility: 'user-or-llm',
58+
description: 'Employee given name',
59+
},
60+
familyName: {
61+
type: 'string',
62+
required: false,
63+
visibility: 'user-or-llm',
64+
description: 'Employee family name',
65+
},
66+
primaryEmail: {
67+
type: 'string',
68+
required: false,
69+
visibility: 'user-or-llm',
70+
description: 'Employee primary email address',
71+
},
72+
primaryPhone: {
73+
type: 'string',
74+
required: false,
75+
visibility: 'user-or-llm',
76+
description: 'Employee primary phone number',
77+
},
78+
primaryAddress: {
79+
type: 'json',
80+
required: false,
81+
visibility: 'user-or-llm',
82+
description: 'Employee primary address',
83+
},
84+
printOnCheckName: {
85+
type: 'string',
86+
required: false,
87+
visibility: 'user-or-llm',
88+
description: 'Employee name printed on checks',
89+
},
90+
billableTime: {
91+
type: 'boolean',
92+
required: false,
93+
visibility: 'user-or-llm',
94+
description: 'Whether employee time is billable',
95+
},
96+
requestId: {
97+
type: 'string',
98+
required: false,
99+
visibility: 'user-or-llm',
100+
description: 'Optional Intuit idempotency request ID, up to 50 characters',
101+
},
102+
},
103+
oauth: {
104+
required: true,
105+
provider: 'quickbooks',
106+
requiredScopes: ['com.intuit.quickbooks.accounting'],
107+
},
108+
errorExtractor: ErrorExtractorId.QUICKBOOKS_FAULT,
109+
request: {
110+
url: (params) =>
111+
addQuickBooksRequestId(
112+
buildQuickBooksEntityUrl(params.realmId, 'employee'),
113+
params.requestId
114+
).toString(),
115+
method: 'POST',
116+
headers: (params) => getQuickBooksToolHeaders(params.accessToken, 'application/json'),
117+
body: (params) =>
118+
filterUndefined({
119+
DisplayName: requiredQuickBooksString(params.displayName, 'displayName'),
120+
GivenName: optionalQuickBooksString(params.givenName),
121+
FamilyName: optionalQuickBooksString(params.familyName),
122+
PrimaryEmailAddr: quickBooksEmailAddress(params.primaryEmail),
123+
PrimaryPhone: quickBooksPhoneNumber(params.primaryPhone),
124+
PrimaryAddr: parseQuickBooksAddress(params.primaryAddress, 'primaryAddress'),
125+
PrintOnCheckName: optionalQuickBooksString(params.printOnCheckName),
126+
BillableTime: params.billableTime,
127+
}),
128+
retry: { enabled: false },
129+
maxResponseBytes: QUICKBOOKS_MAX_RESPONSE_BYTES,
130+
},
131+
transformResponse: (response) =>
132+
transformQuickBooksMutationResponse<QuickBooksEmployee>(
133+
response,
134+
'Employee',
135+
sanitizeQuickBooksEmployee
136+
),
137+
outputs: {
138+
record: {
139+
type: 'json',
140+
description: 'Created QuickBooks Employee record',
141+
properties: QUICKBOOKS_EMPLOYEE_PROPERTIES,
142+
},
143+
...QUICKBOOKS_MUTATION_OUTPUTS,
144+
},
145+
}

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('QuickBooks document tools', () => {
2929
['credit_memo', 'creditmemo'],
3030
['estimate', 'estimate'],
3131
['invoice', 'invoice'],
32+
['payment', 'payment'],
3233
['purchase_order', 'purchaseorder'],
3334
['refund_receipt', 'refundreceipt'],
3435
['sales_receipt', 'salesreceipt'],
@@ -322,11 +323,11 @@ describe('QuickBooks document validation and block parity', () => {
322323
})
323324
})
324325

325-
it('exposes exactly 45 operation/tool pairs and a canonical single-file input pair', () => {
326+
it('exposes exactly 47 operation/tool pairs and a canonical single-file input pair', () => {
326327
const operation = QuickBooksBlock.subBlocks.find((block) => block.id === 'operation')
327-
expect(operation?.options).toHaveLength(45)
328-
expect(QuickBooksBlock.tools?.access).toHaveLength(45)
329-
expect(new Set(QuickBooksBlock.tools?.access).size).toBe(45)
328+
expect(operation?.options).toHaveLength(47)
329+
expect(QuickBooksBlock.tools?.access).toHaveLength(47)
330+
expect(new Set(QuickBooksBlock.tools?.access).size).toBe(47)
330331
expect(operation?.options?.map((option) => option.id).sort()).toEqual(
331332
[...(QuickBooksBlock.tools?.access ?? [])].sort()
332333
)

apps/sim/tools/quickbooks/documents_utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const QUICKBOOKS_DOCUMENT_TRANSACTIONS = {
1313
credit_memo: { entity: 'CreditMemo', resource: 'creditmemo' },
1414
estimate: { entity: 'Estimate', resource: 'estimate' },
1515
invoice: { entity: 'Invoice', resource: 'invoice' },
16+
payment: { entity: 'Payment', resource: 'payment' },
1617
purchase_order: { entity: 'PurchaseOrder', resource: 'purchaseorder' },
1718
refund_receipt: { entity: 'RefundReceipt', resource: 'refundreceipt' },
1819
sales_receipt: { entity: 'SalesReceipt', resource: 'salesreceipt' },
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { resetEnvMock, setEnv } from '@sim/testing'
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3+
import { quickbooksCreateEmployeeTool } from '@/tools/quickbooks/create_employee'
4+
import type {
5+
QuickBooksCreateEmployeeParams,
6+
QuickBooksUpdateEmployeeParams,
7+
} from '@/tools/quickbooks/types'
8+
import { quickbooksUpdateEmployeeTool } from '@/tools/quickbooks/update_employee'
9+
10+
const auth = { accessToken: 'access-token', realmId: '123456789' }
11+
12+
beforeEach(() => setEnv({ QUICKBOOKS_ENV: 'sandbox' }))
13+
afterEach(resetEnvMock)
14+
15+
describe('QuickBooks employee tools', () => {
16+
it('builds a bounded non-payroll employee create request', () => {
17+
const params: QuickBooksCreateEmployeeParams = {
18+
...auth,
19+
displayName: ' Test Employee ',
20+
givenName: 'Test',
21+
familyName: 'Employee',
22+
primaryEmail: 'employee@example.com',
23+
primaryPhone: '555-0100',
24+
primaryAddress: { Line1: '1 Main St', City: 'San Francisco' },
25+
printOnCheckName: 'Test Employee',
26+
billableTime: false,
27+
requestId: 'employee-create-1',
28+
}
29+
const url = new URL(
30+
(
31+
quickbooksCreateEmployeeTool.request.url as (
32+
value: QuickBooksCreateEmployeeParams
33+
) => string
34+
)(params)
35+
)
36+
expect(url.pathname).toBe('/v3/company/123456789/employee')
37+
expect(url.searchParams.get('requestid')).toBe('employee-create-1')
38+
expect(quickbooksCreateEmployeeTool.request.body!(params)).toEqual({
39+
DisplayName: 'Test Employee',
40+
GivenName: 'Test',
41+
FamilyName: 'Employee',
42+
PrimaryEmailAddr: { Address: 'employee@example.com' },
43+
PrimaryPhone: { FreeFormNumber: '555-0100' },
44+
PrimaryAddr: { Line1: '1 Main St', City: 'San Francisco' },
45+
PrintOnCheckName: 'Test Employee',
46+
BillableTime: false,
47+
})
48+
})
49+
50+
it('builds a sparse update and rejects empty updates', () => {
51+
const params: QuickBooksUpdateEmployeeParams = {
52+
...auth,
53+
employeeId: '12',
54+
syncToken: '1',
55+
activeStatus: 'inactive',
56+
billableTime: false,
57+
}
58+
expect(quickbooksUpdateEmployeeTool.request.body!(params)).toEqual({
59+
Id: '12',
60+
SyncToken: '1',
61+
sparse: true,
62+
BillableTime: false,
63+
Active: false,
64+
})
65+
expect(() =>
66+
quickbooksUpdateEmployeeTool.request.body!({
67+
...auth,
68+
employeeId: '12',
69+
syncToken: '1',
70+
activeStatus: 'unchanged',
71+
})
72+
).toThrow('Provide at least one field')
73+
})
74+
75+
it('removes sensitive payroll fields while preserving operational fields', async () => {
76+
await expect(
77+
quickbooksCreateEmployeeTool.transformResponse!(
78+
Response.json({
79+
Employee: {
80+
Id: '12',
81+
SyncToken: '0',
82+
DisplayName: 'Test Employee',
83+
BillableTime: true,
84+
domain: 'QBO',
85+
SSN: '111-22-3333',
86+
TaxIdentifier: 'sensitive',
87+
EmployeeNumber: 'payroll-1',
88+
},
89+
time: 'test-time',
90+
})
91+
)
92+
).resolves.toEqual({
93+
success: true,
94+
output: {
95+
record: {
96+
Id: '12',
97+
SyncToken: '0',
98+
DisplayName: 'Test Employee',
99+
BillableTime: true,
100+
domain: 'QBO',
101+
},
102+
recordId: '12',
103+
syncToken: '0',
104+
time: 'test-time',
105+
},
106+
})
107+
})
108+
})

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,32 @@ afterEach(() => {
6767
})
6868

6969
describe('QuickBooks purchasing reader', () => {
70+
it('supports a vendor filter only for verified purchasing entities', () => {
71+
const requestUrl = quickbooksReadPurchasingTransactionsTool.request.url as (
72+
params: QuickBooksReadPurchasingTransactionsParams
73+
) => string
74+
const url = new URL(
75+
requestUrl({
76+
...authParams,
77+
transactionType: 'bill',
78+
readMode: 'list',
79+
vendorId: '62',
80+
startPosition: 1,
81+
maxResults: 25,
82+
})
83+
)
84+
expect(url.searchParams.get('query')).toContain("WHERE VendorRef = '62'")
85+
expect(() =>
86+
requestUrl({
87+
...authParams,
88+
transactionType: 'purchase',
89+
readMode: 'list',
90+
vendorId: '62',
91+
startPosition: 1,
92+
maxResults: 25,
93+
})
94+
).toThrow('does not support vendorId')
95+
})
7096
const listParams: QuickBooksReadPurchasingTransactionsParams = {
7197
...authParams,
7298
transactionType: 'bill',
@@ -897,11 +923,11 @@ describe('QuickBooks purchasing block', () => {
897923
).toMatchObject({ currentPaymentType: 'check', privateNote: 'Updated' })
898924
})
899925

900-
it('exposes exactly 45 operations with tool/access parity and no old list tools', () => {
926+
it('exposes exactly 47 operations with tool/access parity and no old list tools', () => {
901927
const operation = QuickBooksBlock.subBlocks.find((subBlock) => subBlock.id === 'operation')
902928
const operationIds = (operation?.options ?? []).map((option) => option.id)
903-
expect(operationIds).toHaveLength(45)
904-
expect(new Set(operationIds).size).toBe(45)
929+
expect(operationIds).toHaveLength(47)
930+
expect(new Set(operationIds).size).toBe(47)
905931
expect(operationIds).toEqual(QuickBooksBlock.tools.access)
906932
expect(operationIds).not.toContain('quickbooks_list_bills')
907933
expect(operationIds).not.toContain('quickbooks_list_purchase_orders')

0 commit comments

Comments
 (0)