Skip to content

Commit a21a64e

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): harden master data outputs
1 parent 36762c1 commit a21a64e

4 files changed

Lines changed: 219 additions & 48 deletions

File tree

apps/sim/blocks/blocks/quickbooks.ts

Lines changed: 18 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const PAGINATED_OPERATIONS = [
2424
'quickbooks_list_purchase_orders',
2525
'quickbooks_list_bills',
2626
] as const
27+
const TRANSACTION_LIST_OPERATIONS = [
28+
'quickbooks_list_purchase_orders',
29+
'quickbooks_list_bills',
30+
] as const
2731
const QUICKBOOKS_OPERATIONS = [
2832
'quickbooks_get_company_info',
2933
MASTER_DATA_OPERATION,
@@ -67,6 +71,13 @@ function optionalValue(value: unknown): unknown {
6771
return typeof value === 'string' && value.trim() === '' ? undefined : value
6872
}
6973

74+
function paginationCondition(values?: Record<string, unknown>) {
75+
if (values?.operation === MASTER_DATA_OPERATION) {
76+
return { field: 'readMode', value: 'list' }
77+
}
78+
return { field: 'operation', value: [...TRANSACTION_LIST_OPERATIONS] }
79+
}
80+
7081
export const QuickBooksBlock: BlockConfig<QuickBooksResponse> = {
7182
type: 'quickbooks',
7283
name: 'QuickBooks',
@@ -157,15 +168,7 @@ export const QuickBooksBlock: BlockConfig<QuickBooksResponse> = {
157168
type: 'short-input',
158169
placeholder: '1',
159170
mode: 'advanced',
160-
condition: {
161-
field: 'operation',
162-
value: [...PAGINATED_OPERATIONS],
163-
and: {
164-
field: 'readMode',
165-
value: 'by_id',
166-
not: true,
167-
},
168-
},
171+
condition: paginationCondition,
169172
value: () => '1',
170173
},
171174
{
@@ -174,15 +177,7 @@ export const QuickBooksBlock: BlockConfig<QuickBooksResponse> = {
174177
type: 'short-input',
175178
placeholder: '25',
176179
mode: 'advanced',
177-
condition: {
178-
field: 'operation',
179-
value: [...PAGINATED_OPERATIONS],
180-
and: {
181-
field: 'readMode',
182-
value: 'by_id',
183-
not: true,
184-
},
185-
},
180+
condition: paginationCondition,
186181
value: () => '25',
187182
},
188183
{
@@ -620,47 +615,27 @@ export const QuickBooksBlock: BlockConfig<QuickBooksResponse> = {
620615
type: 'array',
621616
description:
622617
'Account, Customer, Vendor, Item, Employee, PurchaseOrder, or Bill objects with native QuickBooks fields',
623-
condition: {
624-
field: 'operation',
625-
value: [...PAGINATED_OPERATIONS],
626-
and: { field: 'readMode', value: 'by_id', not: true },
627-
},
618+
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
628619
},
629620
startPosition: {
630621
type: 'number',
631622
description: 'One-based position of the first returned list item',
632-
condition: {
633-
field: 'operation',
634-
value: [...PAGINATED_OPERATIONS],
635-
and: { field: 'readMode', value: 'by_id', not: true },
636-
},
623+
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
637624
},
638625
maxResults: {
639626
type: 'number',
640627
description: 'Actual number of items reported for the list response',
641-
condition: {
642-
field: 'operation',
643-
value: [...PAGINATED_OPERATIONS],
644-
and: { field: 'readMode', value: 'by_id', not: true },
645-
},
628+
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
646629
},
647630
nextStartPosition: {
648631
type: 'number',
649632
description: 'Position to pass into an explicit next-page request',
650-
condition: {
651-
field: 'operation',
652-
value: [...PAGINATED_OPERATIONS],
653-
and: { field: 'readMode', value: 'by_id', not: true },
654-
},
633+
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
655634
},
656635
hasMore: {
657636
type: 'boolean',
658637
description: 'Conservative indication that another list page may exist',
659-
condition: {
660-
field: 'operation',
661-
value: [...PAGINATED_OPERATIONS],
662-
and: { field: 'readMode', value: 'by_id', not: true },
663-
},
638+
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
664639
},
665640
record: {
666641
type: 'json',

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

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getQuickBooksUserInfoUrl,
88
QUICKBOOKS_MAX_RESPONSE_BYTES,
99
} from '@/tools/quickbooks/client'
10+
import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
1011
import { quickbooksCreateCustomerTool } from '@/tools/quickbooks/create_customer'
1112
import { quickbooksCreateItemTool } from '@/tools/quickbooks/create_item'
1213
import { quickbooksCreateVendorTool } from '@/tools/quickbooks/create_vendor'
@@ -299,6 +300,74 @@ describe('QuickBooks master-data reader', () => {
299300
})
300301
})
301302

303+
it('allowlists Employee output fields and removes sensitive provider properties', async () => {
304+
const employee = {
305+
Id: '5',
306+
SyncToken: '2',
307+
DisplayName: 'Sanitized Employee',
308+
Active: true,
309+
SSN: '000-00-0000',
310+
BankAccountNumber: 'sensitive',
311+
PrimaryEmailAddr: {
312+
Address: 'employee@example.test',
313+
VerificationToken: 'sensitive',
314+
},
315+
PrimaryAddr: {
316+
Line1: '123 Main St',
317+
PostalCode: '94105',
318+
InternalGeoCode: 'sensitive',
319+
},
320+
MetaData: {
321+
CreateTime: '2026-01-01T00:00:00Z',
322+
InternalRevision: 'sensitive',
323+
},
324+
}
325+
const listParamsForEmployee: QuickBooksReadMasterDataParams = {
326+
...listParams,
327+
recordType: 'employee',
328+
}
329+
330+
await expect(
331+
quickbooksReadMasterDataTool.transformResponse!(
332+
Response.json({ QueryResponse: { Employee: [employee] } }),
333+
listParamsForEmployee
334+
)
335+
).resolves.toMatchObject({
336+
success: true,
337+
output: {
338+
recordType: 'employee',
339+
items: [
340+
{
341+
Id: '5',
342+
SyncToken: '2',
343+
DisplayName: 'Sanitized Employee',
344+
Active: true,
345+
PrimaryEmailAddr: { Address: 'employee@example.test' },
346+
PrimaryAddr: { Line1: '123 Main St', PostalCode: '94105' },
347+
MetaData: { CreateTime: '2026-01-01T00:00:00Z' },
348+
},
349+
],
350+
},
351+
})
352+
353+
const listResult = await quickbooksReadMasterDataTool.transformResponse!(
354+
Response.json({ QueryResponse: { Employee: [employee] } }),
355+
listParamsForEmployee
356+
)
357+
expect(listResult.output.items?.[0]).not.toHaveProperty('SSN')
358+
expect(listResult.output.items?.[0]).not.toHaveProperty('BankAccountNumber')
359+
expect(listResult.output.items?.[0]?.PrimaryEmailAddr).not.toHaveProperty('VerificationToken')
360+
expect(listResult.output.items?.[0]?.PrimaryAddr).not.toHaveProperty('InternalGeoCode')
361+
expect(listResult.output.items?.[0]?.MetaData).not.toHaveProperty('InternalRevision')
362+
363+
const byIdResult = await quickbooksReadMasterDataTool.transformResponse!(
364+
Response.json({ Employee: employee }),
365+
{ ...listParamsForEmployee, readMode: 'by_id', recordId: '5' }
366+
)
367+
expect(byIdResult.output.item).not.toHaveProperty('SSN')
368+
expect(byIdResult.output.item).not.toHaveProperty('BankAccountNumber')
369+
})
370+
302371
it('rejects missing IDs, unknown types and unknown modes before a request', () => {
303372
const requestUrl = quickbooksReadMasterDataTool.request.url as (
304373
params: QuickBooksReadMasterDataParams
@@ -672,9 +741,37 @@ describe('QuickBooks tool and block boundaries', () => {
672741
and: { field: 'readMode', value: 'by_id' },
673742
})
674743
expect(subBlocks.startPosition.mode).toBe('advanced')
675-
expect(subBlocks.startPosition.condition).toMatchObject({
744+
expect(
745+
evaluateSubBlockCondition(subBlocks.startPosition.condition, {
746+
operation: 'quickbooks_read_master_data',
747+
readMode: 'list',
748+
})
749+
).toBe(true)
750+
expect(
751+
evaluateSubBlockCondition(subBlocks.startPosition.condition, {
752+
operation: 'quickbooks_read_master_data',
753+
readMode: 'by_id',
754+
})
755+
).toBe(false)
756+
expect(
757+
evaluateSubBlockCondition(subBlocks.startPosition.condition, {
758+
operation: 'quickbooks_list_purchase_orders',
759+
readMode: 'by_id',
760+
})
761+
).toBe(true)
762+
expect(
763+
evaluateSubBlockCondition(subBlocks.maxResults.condition, {
764+
operation: 'quickbooks_list_bills',
765+
readMode: 'by_id',
766+
})
767+
).toBe(true)
768+
expect(QuickBooksBlock.outputs.items.condition).toEqual({
676769
field: 'operation',
677-
and: { field: 'readMode', value: 'by_id', not: true },
770+
value: [
771+
'quickbooks_read_master_data',
772+
'quickbooks_list_purchase_orders',
773+
'quickbooks_list_bills',
774+
],
678775
})
679776
expect(subBlocks.syncToken.condition).toEqual({
680777
field: 'operation',

apps/sim/tools/quickbooks/read_master_data.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { QUICKBOOKS_MAX_RESPONSE_BYTES } from '@/tools/quickbooks/client'
22
import { ErrorExtractorId } from '@/tools/error-extractors'
33
import type {
4+
QuickBooksAddress,
5+
QuickBooksEmployee,
46
QuickBooksMasterDataRecord,
57
QuickBooksReadMasterDataParams,
68
QuickBooksReadMasterDataResponse,
@@ -19,6 +21,102 @@ import {
1921
} from '@/tools/quickbooks/utils'
2022
import type { ToolConfig } from '@/tools/types'
2123

24+
function optionalString(value: unknown): string | undefined {
25+
return typeof value === 'string' ? value : undefined
26+
}
27+
28+
function optionalBoolean(value: unknown): boolean | undefined {
29+
return typeof value === 'boolean' ? value : undefined
30+
}
31+
32+
function sanitizeEmployeeAddress(value: unknown): QuickBooksAddress | undefined {
33+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
34+
const source = value as Record<string, unknown>
35+
const address: QuickBooksAddress = {}
36+
for (const key of [
37+
'Id',
38+
'Line1',
39+
'Line2',
40+
'Line3',
41+
'Line4',
42+
'Line5',
43+
'City',
44+
'Country',
45+
'CountrySubDivisionCode',
46+
'PostalCode',
47+
'Lat',
48+
'Long',
49+
] as const) {
50+
const field = optionalString(source[key])
51+
if (field !== undefined) address[key] = field
52+
}
53+
return Object.keys(address).length > 0 ? address : undefined
54+
}
55+
56+
function sanitizeEmployee(value: QuickBooksMasterDataRecord): QuickBooksEmployee {
57+
const source = value as Record<string, unknown>
58+
const id = optionalString(source.Id)?.trim()
59+
if (!id) throw new Error('QuickBooks Employee response is missing Id')
60+
61+
const employee: QuickBooksEmployee = { Id: id }
62+
for (const key of [
63+
'SyncToken',
64+
'DisplayName',
65+
'GivenName',
66+
'MiddleName',
67+
'FamilyName',
68+
'Suffix',
69+
'Title',
70+
'PrintOnCheckName',
71+
] as const) {
72+
const field = optionalString(source[key])
73+
if (field !== undefined) employee[key] = field
74+
}
75+
for (const key of ['Active', 'BillableTime', 'sparse'] as const) {
76+
const field = optionalBoolean(source[key])
77+
if (field !== undefined) employee[key] = field
78+
}
79+
80+
const primaryPhone = source.PrimaryPhone
81+
if (primaryPhone && typeof primaryPhone === 'object' && !Array.isArray(primaryPhone)) {
82+
const freeFormNumber = optionalString((primaryPhone as Record<string, unknown>).FreeFormNumber)
83+
if (freeFormNumber !== undefined) employee.PrimaryPhone = { FreeFormNumber: freeFormNumber }
84+
}
85+
const mobile = source.Mobile
86+
if (mobile && typeof mobile === 'object' && !Array.isArray(mobile)) {
87+
const freeFormNumber = optionalString((mobile as Record<string, unknown>).FreeFormNumber)
88+
if (freeFormNumber !== undefined) employee.Mobile = { FreeFormNumber: freeFormNumber }
89+
}
90+
const primaryEmail = source.PrimaryEmailAddr
91+
if (primaryEmail && typeof primaryEmail === 'object' && !Array.isArray(primaryEmail)) {
92+
const address = optionalString((primaryEmail as Record<string, unknown>).Address)
93+
if (address !== undefined) employee.PrimaryEmailAddr = { Address: address }
94+
}
95+
96+
employee.PrimaryAddr = sanitizeEmployeeAddress(source.PrimaryAddr)
97+
const metadata = source.MetaData
98+
if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) {
99+
const metadataSource = metadata as Record<string, unknown>
100+
const createTime = optionalString(metadataSource.CreateTime)
101+
const lastUpdatedTime = optionalString(metadataSource.LastUpdatedTime)
102+
if (createTime !== undefined || lastUpdatedTime !== undefined) {
103+
employee.MetaData = {
104+
...(createTime !== undefined ? { CreateTime: createTime } : {}),
105+
...(lastUpdatedTime !== undefined ? { LastUpdatedTime: lastUpdatedTime } : {}),
106+
}
107+
}
108+
}
109+
110+
return employee
111+
}
112+
113+
function sanitizeMasterDataRecord(
114+
recordType: QuickBooksReadMasterDataParams['recordType'],
115+
value: QuickBooksMasterDataRecord
116+
): QuickBooksMasterDataRecord {
117+
return recordType === 'employee' ? sanitizeEmployee(value) : value
118+
}
119+
22120
export const quickbooksReadMasterDataTool: ToolConfig<
23121
QuickBooksReadMasterDataParams,
24122
QuickBooksReadMasterDataResponse
@@ -121,6 +219,9 @@ export const quickbooksReadMasterDataTool: ToolConfig<
121219
output: {
122220
recordType: params.recordType,
123221
...result.output,
222+
items: result.output.items.map((item) =>
223+
sanitizeMasterDataRecord(params.recordType, item)
224+
),
124225
},
125226
}
126227
}
@@ -133,7 +234,7 @@ export const quickbooksReadMasterDataTool: ToolConfig<
133234
success: true,
134235
output: {
135236
recordType: params.recordType,
136-
item: result.item,
237+
item: sanitizeMasterDataRecord(params.recordType, result.item),
137238
time: result.time,
138239
},
139240
}

apps/sim/tools/quickbooks/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,9 @@ export interface QuickBooksEmployee {
9898
Mobile?: QuickBooksPhoneNumber
9999
PrimaryEmailAddr?: QuickBooksEmailAddress
100100
PrimaryAddr?: QuickBooksAddress
101-
SSN?: string
102101
BillableTime?: boolean
103102
MetaData?: QuickBooksMetaData
104103
sparse?: boolean
105-
[key: string]: unknown
106104
}
107105

108106
export interface QuickBooksItem {

0 commit comments

Comments
 (0)