Skip to content

Commit e334bbc

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): address independent sales review
1 parent c166427 commit e334bbc

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,
@@ -1027,27 +1027,27 @@ export const QuickBooksBlock: BlockConfig<QuickBooksResponse> = {
10271027
type: 'array',
10281028
description:
10291029
'Master-data, sales transaction, PurchaseOrder, or Bill objects with native QuickBooks fields',
1030-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1030+
condition: LIST_OUTPUT_CONDITION,
10311031
},
10321032
startPosition: {
10331033
type: 'number',
10341034
description: 'One-based position of the first returned list item',
1035-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1035+
condition: LIST_OUTPUT_CONDITION,
10361036
},
10371037
maxResults: {
10381038
type: 'number',
10391039
description: 'Actual number of items reported for the list response',
1040-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1040+
condition: LIST_OUTPUT_CONDITION,
10411041
},
10421042
nextStartPosition: {
10431043
type: 'number',
10441044
description: 'Position to pass into an explicit next-page request',
1045-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1045+
condition: LIST_OUTPUT_CONDITION,
10461046
},
10471047
hasMore: {
10481048
type: 'boolean',
10491049
description: 'Conservative indication that another list page may exist',
1050-
condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] },
1050+
condition: LIST_OUTPUT_CONDITION,
10511051
},
10521052
record: {
10531053
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 {
@@ -865,13 +866,32 @@ describe('QuickBooks tool and block boundaries', () => {
865866
).toBe(true)
866867
expect(QuickBooksBlock.outputs.items.condition).toEqual({
867868
field: 'operation',
868-
value: [
869-
'quickbooks_read_master_data',
870-
'quickbooks_read_sales_transactions',
871-
'quickbooks_list_purchase_orders',
872-
'quickbooks_list_bills',
873-
],
869+
value: ['quickbooks_read_master_data', 'quickbooks_read_sales_transactions'],
870+
and: { field: 'readMode', value: 'list' },
871+
or: {
872+
field: 'operation',
873+
value: ['quickbooks_list_purchase_orders', 'quickbooks_list_bills'],
874+
},
874875
})
876+
const listOutputCondition = QuickBooksBlock.outputs.items.condition!
877+
expect(
878+
evaluateOutputCondition(listOutputCondition, {
879+
operation: { value: 'quickbooks_read_sales_transactions' },
880+
readMode: { value: 'by_id' },
881+
})
882+
).toBe(false)
883+
expect(
884+
evaluateOutputCondition(listOutputCondition, {
885+
operation: { value: 'quickbooks_read_sales_transactions' },
886+
readMode: { value: 'list' },
887+
})
888+
).toBe(true)
889+
expect(
890+
evaluateOutputCondition(listOutputCondition, {
891+
operation: { value: 'quickbooks_list_bills' },
892+
readMode: { value: 'by_id' },
893+
})
894+
).toBe(true)
875895
expect(subBlocks.syncToken.condition).toEqual({
876896
field: 'operation',
877897
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
@@ -43,6 +43,17 @@ interface QuickBooksQueryResponse<T> {
4343
time?: string
4444
}
4545

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

175-
const items = (candidate ?? []) as T[]
186+
const items = (candidate ?? []).map((item) => assertQuickBooksEntity<T>(item, entity))
176187
const startPosition = Number.isInteger(queryResponse.startPosition)
177188
? (queryResponse.startPosition as number)
178189
: params.startPosition
@@ -201,11 +212,11 @@ export async function transformQuickBooksEntityResponse<
201212
`QuickBooks ${entity} response`
202213
)
203214
const candidate = data[entity]
204-
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
215+
if (!candidate) {
205216
throw new Error(`QuickBooks ${entity} response is missing ${entity}`)
206217
}
207218
return {
208-
item: candidate as T,
219+
item: assertQuickBooksEntity<T>(candidate, entity),
209220
time: typeof data.time === 'string' ? data.time : null,
210221
}
211222
}

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)