Skip to content

Commit 132f2b5

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): address independent sales review
1 parent 4e48327 commit 132f2b5

8 files changed

Lines changed: 118 additions & 24 deletions

File tree

apps/docs/content/docs/en/integrations/quickbooks.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1010
color="#2CA01C"
1111
/>
1212

13+
{/* MANUAL-CONTENT-START:intro */}
14+
Connect one QuickBooks Online company per credential. During OAuth, choose the company that the workflow should access; Sim binds that company to the credential automatically, so you do not enter a realm ID or API host.
15+
16+
Master Data and Sales transaction reads support **List** and **By ID** modes. List actions return at most one page. Use `nextStartPosition` in another workflow step when `hasMore` is true. Sim does not paginate, retry, or fetch related records automatically.
17+
18+
QuickBooks updates are sparse: provide the record ID, its current `SyncToken`, and only the fields you want to change. Use the latest `SyncToken` returned by a read or mutation. Voiding keeps the transaction in QuickBooks with a zeroed financial effect; it is not deletion and requires explicit confirmation. Create actions accept an optional `requestId` that QuickBooks uses for idempotency when the same request may be submitted again.
19+
20+
Sandbox credentials call only Intuit's sandbox API and are suitable for disposable test data. Production credentials call the production API and affect the selected live company.
21+
22+
Reports and attachments are not exposed by this version of the block, so report date-range and attachment-size controls do not apply here.
23+
{/* MANUAL-CONTENT-END */}
24+
25+
1326
## Usage Instructions
1427

1528
Connect one QuickBooks Online company to manage master data and bounded sales and receivables workflows, while preserving existing purchase-order and bill reads.

apps/sim/blocks/blocks/quickbooks.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { QuickBooksIcon } from '@/components/icons'
22
import { getScopesForService } from '@/lib/oauth/utils'
3-
import type { BlockConfig, BlockMeta } from '@/blocks/types'
3+
import type { BlockConfig, BlockMeta, OutputCondition } from '@/blocks/types'
44
import { AuthMode, IntegrationType } from '@/blocks/types'
55
import {
66
parseQuickBooksInvoiceAllocations,
@@ -69,16 +69,16 @@ const MUTATION_OPERATIONS = [
6969
...VENDOR_OPERATIONS,
7070
...SALES_MUTATION_OPERATIONS,
7171
] as const
72-
const PAGINATED_OPERATIONS = [
73-
MASTER_DATA_OPERATION,
74-
SALES_READ_OPERATION,
75-
'quickbooks_list_purchase_orders',
76-
'quickbooks_list_bills',
77-
] as const
7872
const TRANSACTION_LIST_OPERATIONS = [
7973
'quickbooks_list_purchase_orders',
8074
'quickbooks_list_bills',
8175
] as const
76+
const LIST_OUTPUT_CONDITION: OutputCondition = {
77+
field: 'operation',
78+
value: [MASTER_DATA_OPERATION, SALES_READ_OPERATION],
79+
and: { field: 'readMode', value: 'list' },
80+
or: { field: 'operation', value: [...TRANSACTION_LIST_OPERATIONS] },
81+
}
8282
const QUICKBOOKS_OPERATIONS = [
8383
'quickbooks_get_company_info',
8484
MASTER_DATA_OPERATION,
@@ -1022,27 +1022,27 @@ export const QuickBooksBlock: BlockConfig<QuickBooksResponse> = {
10221022
type: 'array',
10231023
description:
10241024
'Master-data, sales transaction, PurchaseOrder, or Bill objects with native QuickBooks fields',
1025-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1025+
condition: LIST_OUTPUT_CONDITION,
10261026
},
10271027
startPosition: {
10281028
type: 'number',
10291029
description: 'One-based position of the first returned list item',
1030-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1030+
condition: LIST_OUTPUT_CONDITION,
10311031
},
10321032
maxResults: {
10331033
type: 'number',
10341034
description: 'Actual number of items reported for the list response',
1035-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1035+
condition: LIST_OUTPUT_CONDITION,
10361036
},
10371037
nextStartPosition: {
10381038
type: 'number',
10391039
description: 'Position to pass into an explicit next-page request',
1040-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1040+
condition: LIST_OUTPUT_CONDITION,
10411041
},
10421042
hasMore: {
10431043
type: 'boolean',
10441044
description: 'Conservative indication that another list page may exist',
1045-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1045+
condition: LIST_OUTPUT_CONDITION,
10461046
},
10471047
record: {
10481048
type: 'json',

apps/sim/lib/workflows/blocks/block-outputs.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function isConditionPrimitive(value: unknown): value is ConditionValue {
5252
* Evaluates an output condition against subBlock values.
5353
* Returns true if the condition is met and the output should be shown.
5454
*/
55-
function evaluateOutputCondition(
55+
export function evaluateOutputCondition(
5656
condition: OutputCondition,
5757
subBlocks: Record<string, SubBlockWithValue> | undefined
5858
): boolean {
@@ -93,6 +93,10 @@ function evaluateOutputCondition(
9393
matches = matches && andMatches
9494
}
9595

96+
if (condition.or) {
97+
matches = matches || evaluateOutputCondition(condition.or, subBlocks)
98+
}
99+
96100
return matches
97101
}
98102

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { resetEnvMock, setEnv } from '@sim/testing'
22
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3+
import { evaluateOutputCondition } from '@/lib/workflows/blocks/block-outputs'
34
import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
45
import { QuickBooksBlock } from '@/blocks/blocks/quickbooks'
56
import {
@@ -953,13 +954,32 @@ describe('QuickBooks tool and block boundaries', () => {
953954
})
954955
expect(QuickBooksBlock.outputs.items.condition).toEqual({
955956
field: 'operation',
956-
value: [
957-
'quickbooks_read_master_data',
958-
'quickbooks_read_sales_transactions',
959-
'quickbooks_list_purchase_orders',
960-
'quickbooks_list_bills',
961-
],
957+
value: ['quickbooks_read_master_data', 'quickbooks_read_sales_transactions'],
958+
and: { field: 'readMode', value: 'list' },
959+
or: {
960+
field: 'operation',
961+
value: ['quickbooks_list_purchase_orders', 'quickbooks_list_bills'],
962+
},
962963
})
964+
const listOutputCondition = QuickBooksBlock.outputs.items.condition!
965+
expect(
966+
evaluateOutputCondition(listOutputCondition, {
967+
operation: { value: 'quickbooks_read_sales_transactions' },
968+
readMode: { value: 'by_id' },
969+
})
970+
).toBe(false)
971+
expect(
972+
evaluateOutputCondition(listOutputCondition, {
973+
operation: { value: 'quickbooks_read_sales_transactions' },
974+
readMode: { value: 'list' },
975+
})
976+
).toBe(true)
977+
expect(
978+
evaluateOutputCondition(listOutputCondition, {
979+
operation: { value: 'quickbooks_list_bills' },
980+
readMode: { value: 'by_id' },
981+
})
982+
).toBe(true)
963983
expect(subBlocks.syncToken.condition).toEqual({
964984
field: 'operation',
965985
value: [

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,23 @@ describe('QuickBooks sales reader', () => {
129129
})
130130
})
131131

132+
it('rejects malformed records without usable QuickBooks IDs', async () => {
133+
await expect(
134+
quickbooksReadSalesTransactionsTool.transformResponse!(
135+
Response.json({ QueryResponse: { Invoice: [null] } }),
136+
listParams
137+
)
138+
).rejects.toThrow('malformed Invoice record')
139+
140+
await expect(
141+
quickbooksReadSalesTransactionsTool.transformResponse!(Response.json({ Invoice: {} }), {
142+
...listParams,
143+
readMode: 'by_id',
144+
transactionId: '12',
145+
})
146+
).rejects.toThrow('without an Id')
147+
})
148+
132149
it('rejects unknown types, modes, and missing by-ID values before a request', () => {
133150
const requestUrl = quickbooksReadSalesTransactionsTool.request.url as (
134151
params: QuickBooksReadSalesTransactionsParams
@@ -328,6 +345,17 @@ describe('QuickBooks customer payments and voids', () => {
328345
invoiceAllocations: [{ invoiceId: '12', amount: 101 }],
329346
})
330347
).toThrow('cannot exceed')
348+
349+
expect(
350+
buildQuickBooksCreatePaymentBody({
351+
...params,
352+
totalAmount: 0.06,
353+
invoiceAllocations: [
354+
{ invoiceId: '12', amount: 0.01 },
355+
{ invoiceId: '13', amount: 0.05 },
356+
],
357+
})
358+
).toMatchObject({ TotalAmt: 0.06 })
331359
})
332360

333361
it('builds sparse payment updates and rejects allocations without a replacement total', () => {
@@ -353,6 +381,19 @@ describe('QuickBooks customer payments and voids', () => {
353381
invoiceAllocations: [{ invoiceId: '12', amount: 25 }],
354382
})
355383
).toThrow('totalAmount is required')
384+
385+
expect(
386+
buildQuickBooksUpdatePaymentBody({
387+
...authParams,
388+
paymentId: '15',
389+
syncToken: '2',
390+
totalAmount: 0.06,
391+
invoiceAllocations: [
392+
{ invoiceId: '12', amount: 0.01 },
393+
{ invoiceId: '13', amount: 0.05 },
394+
],
395+
})
396+
).toMatchObject({ TotalAmt: 0.06 })
356397
})
357398

358399
it('uses the verified payment endpoints', () => {

apps/sim/tools/quickbooks/sales_utils.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { filterUndefined } from '@sim/utils/object'
2+
import Decimal from 'decimal.js'
23
import type {
34
QuickBooksCreateCustomerPaymentParams,
45
QuickBooksCreateSalesDocumentParams,
@@ -190,8 +191,11 @@ function buildPaymentLines(
190191
if (totalAmount === undefined) {
191192
throw new Error('totalAmount is required when invoice allocations are supplied')
192193
}
193-
const allocationTotal = allocations.reduce((sum, allocation) => sum + allocation.amount, 0)
194-
if (allocationTotal > totalAmount) {
194+
const allocationTotal = allocations.reduce(
195+
(sum, allocation) => sum.plus(allocation.amount),
196+
new Decimal(0)
197+
)
198+
if (allocationTotal.greaterThan(totalAmount)) {
195199
throw new Error('Invoice allocation amounts cannot exceed totalAmount')
196200
}
197201
return allocations.map((allocation) => ({

apps/sim/tools/quickbooks/utils.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ interface QuickBooksQueryResponse<T> {
4444
time?: string
4545
}
4646

47+
function assertQuickBooksEntity<T>(candidate: unknown, entity: QuickBooksQueryEntity): T {
48+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
49+
throw new Error(`QuickBooks ${entity} response contains a malformed ${entity} record`)
50+
}
51+
const recordId = (candidate as { Id?: unknown }).Id
52+
if (typeof recordId !== 'string' || !recordId.trim()) {
53+
throw new Error(`QuickBooks ${entity} response contains a record without an Id`)
54+
}
55+
return candidate as T
56+
}
57+
4758
export const QUICKBOOKS_MASTER_DATA_ENTITIES = {
4859
account: { entity: 'Account', resource: 'account' },
4960
customer: { entity: 'Customer', resource: 'customer' },
@@ -173,7 +184,7 @@ export async function transformQuickBooksListResponse<T>(
173184
throw new Error(`QuickBooks ${entity} response contains a malformed entity list`)
174185
}
175186

176-
const items = (candidate ?? []) as T[]
187+
const items = (candidate ?? []).map((item) => assertQuickBooksEntity<T>(item, entity))
177188
const startPosition = Number.isInteger(queryResponse.startPosition)
178189
? (queryResponse.startPosition as number)
179190
: params.startPosition
@@ -202,11 +213,11 @@ export async function transformQuickBooksEntityResponse<
202213
`QuickBooks ${entity} response`
203214
)
204215
const candidate = data[entity]
205-
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
216+
if (!candidate) {
206217
throw new Error(`QuickBooks ${entity} response is missing ${entity}`)
207218
}
208219
return {
209-
item: candidate as T,
220+
item: assertQuickBooksEntity<T>(candidate, entity),
210221
time: typeof data.time === 'string' ? data.time : null,
211222
}
212223
}

packages/workflow-types/src/blocks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface OutputCondition {
7171
| null
7272
not?: boolean
7373
}
74+
or?: OutputCondition
7475
}
7576

7677
export type OutputFieldDefinition =

0 commit comments

Comments
 (0)