Skip to content

Commit 53b7c94

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(quickbooks): align entity and attachment contracts
1 parent 0d7d8b1 commit 53b7c94

10 files changed

Lines changed: 351 additions & 25 deletions

File tree

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

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

13+
{/* MANUAL-CONTENT-START:intro */}
14+
## Setup
15+
16+
Connect QuickBooks with OAuth 2.0, then enter the `realmId` for the company you authorized. Intuit returns this company ID as the `realmId` query parameter during the OAuth callback. Use a sandbox company ID with sandbox credentials and a production company ID with production credentials.
17+
18+
QuickBooks Online does not provide a permanent API key or static per-user token for Accounting API access. Access tokens expire and are renewed through the connected OAuth credential.
19+
20+
Entity availability can depend on the QuickBooks locale or subscription. For example, `TaxPayment` is limited to supported non-US locales, `JournalCode` is France-only, `RecurringTransaction` requires Essentials, Plus, or Advanced, and `InventoryAdjustment` requires a supported US Plus or Advanced company.
21+
{/* MANUAL-CONTENT-END */}
22+
23+
1324
## Usage Instructions
1425

1526
Integrate QuickBooks Online into procurement and accounting workflows. Manage supported records, inventory adjustments, preferences, currencies, reports, attachments, PDFs, change-data-capture syncs, batches, and custom queries.
@@ -477,14 +488,14 @@ Update a supported QuickBooks Online accounting record
477488

478489
### `quickbooks_upload_attachment`
479490

480-
Upload and link a file to a QuickBooks transaction or list entity
491+
Upload and link a supported file up to 25 MB to a QuickBooks transaction or item
481492

482493
#### Input
483494

484495
| Parameter | Type | Required | Description |
485496
| --------- | ---- | -------- | ----------- |
486497
| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth |
487-
| `file` | file | Yes | File to upload and link in QuickBooks |
498+
| `file` | file | Yes | Supported attachment file up to 25 MB to upload and link in QuickBooks |
488499
| `entity` | string | Yes | QuickBooks entity type to link the attachment to |
489500
| `entityId` | string | Yes | QuickBooks entity ID to link the attachment to |
490501
| `note` | string | No | Optional note stored with the attachment |

apps/sim/app/api/tools/quickbooks/upload-attachment/route.test.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,60 @@ describe('POST /api/tools/quickbooks/upload-attachment', () => {
152152
)
153153
})
154154

155+
it('rejects unsupported linked entity types before downloading the file', async () => {
156+
const response = await POST(
157+
createMockRequest('POST', {
158+
...baseBody,
159+
entity: 'CompanyInfo',
160+
})
161+
)
162+
163+
expect(response.status).toBe(400)
164+
await expect(response.json()).resolves.toEqual({
165+
success: false,
166+
error: 'QuickBooks entity "CompanyInfo" cannot be linked to an attachment',
167+
})
168+
expect(mockDownloadFileFromStorage).not.toHaveBeenCalled()
169+
expect(mockFetch).not.toHaveBeenCalled()
170+
})
171+
172+
it('rejects file types outside the QuickBooks attachment whitelist', async () => {
173+
mockProcessFilesToUserFiles.mockReturnValueOnce([
174+
{
175+
...baseBody.file,
176+
key: 'uploads/archive.zip',
177+
name: 'archive.zip',
178+
type: 'application/zip',
179+
},
180+
])
181+
182+
const response = await POST(createMockRequest('POST', baseBody))
183+
184+
expect(response.status).toBe(400)
185+
await expect(response.json()).resolves.toEqual({
186+
success: false,
187+
error: 'QuickBooks does not support .zip attachment files',
188+
})
189+
expect(mockDownloadFileFromStorage).not.toHaveBeenCalled()
190+
expect(mockFetch).not.toHaveBeenCalled()
191+
})
192+
193+
it('rejects resolved content types that do not match the file extension', async () => {
194+
mockDownloadFileFromStorage.mockResolvedValueOnce({
195+
buffer: Buffer.from('not-a-pdf'),
196+
contentType: 'text/plain',
197+
})
198+
199+
const response = await POST(createMockRequest('POST', baseBody))
200+
201+
expect(response.status).toBe(400)
202+
await expect(response.json()).resolves.toEqual({
203+
success: false,
204+
error: 'QuickBooks does not support text/plain content for .pdf attachments',
205+
})
206+
expect(mockFetch).not.toHaveBeenCalled()
207+
})
208+
155209
it('rejects nested QuickBooks upload faults returned with HTTP 200', async () => {
156210
mockFetch.mockResolvedValueOnce(
157211
new Response(
@@ -193,16 +247,16 @@ describe('POST /api/tools/quickbooks/upload-attachment', () => {
193247
})
194248
})
195249

196-
it('rejects files over the QuickBooks 100 MB attachment limit', async () => {
250+
it('rejects files over the buffered 25 MB attachment limit', async () => {
197251
mockProcessFilesToUserFiles.mockReturnValueOnce([
198-
{ ...baseBody.file, size: 100 * 1024 * 1024 + 1 },
252+
{ ...baseBody.file, size: 25 * 1024 * 1024 + 1 },
199253
])
200254

201255
const response = await POST(createMockRequest('POST', baseBody))
202256
expect(response.status).toBe(400)
203257
await expect(response.json()).resolves.toMatchObject({
204258
success: false,
205-
error: expect.stringContaining('100MB'),
259+
error: expect.stringContaining('25MB'),
206260
})
207261
expect(mockFetch).not.toHaveBeenCalled()
208262
})

apps/sim/app/api/tools/quickbooks/upload-attachment/route.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { extname } from 'node:path'
12
import { createLogger } from '@sim/logger'
23
import { getErrorMessage } from '@sim/utils/errors'
34
import { type NextRequest, NextResponse } from 'next/server'
@@ -22,19 +23,60 @@ import {
2223
export const dynamic = 'force-dynamic'
2324

2425
const logger = createLogger('QuickBooksUploadAttachmentAPI')
25-
const QUICKBOOKS_MAX_UPLOAD_BYTES = 100 * 1024 * 1024
26+
const QUICKBOOKS_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
27+
const QUICKBOOKS_ATTACHMENT_CONTENT_TYPES: Record<string, readonly string[]> = {
28+
ai: ['application/postscript'],
29+
csv: ['text/csv'],
30+
doc: ['application/msword'],
31+
docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
32+
eps: ['application/postscript'],
33+
gif: ['image/gif'],
34+
jpeg: ['image/jpeg'],
35+
jpg: ['image/jpeg', 'image/jpg'],
36+
ods: ['application/vnd.oasis.opendocument.spreadsheet'],
37+
pdf: ['application/pdf'],
38+
png: ['image/png'],
39+
rtf: ['application/rtf', 'text/rtf'],
40+
tif: ['image/tiff'],
41+
txt: ['text/plain'],
42+
xls: ['application/vnd.ms-excel'],
43+
xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
44+
xml: ['application/xml', 'text/xml'],
45+
}
2646

2747
function uploadSizeError(bytes: number): NextResponse {
2848
const sizeMb = (bytes / (1024 * 1024)).toFixed(2)
2949
return NextResponse.json(
3050
{
3151
success: false,
32-
error: `File size (${sizeMb}MB) exceeds QuickBooks attachment limit of 100MB`,
52+
error: `File size (${sizeMb}MB) exceeds Sim's QuickBooks attachment limit of 25MB`,
3353
},
3454
{ status: 400 }
3555
)
3656
}
3757

58+
function attachmentValidationError(error: unknown): NextResponse {
59+
return NextResponse.json(
60+
{ success: false, error: getErrorMessage(error, 'Invalid QuickBooks attachment') },
61+
{ status: 400 }
62+
)
63+
}
64+
65+
function validateQuickBooksAttachmentFile(fileName: string, contentType?: string): void {
66+
const extension = extname(fileName).slice(1).toLowerCase()
67+
const allowedContentTypes = QUICKBOOKS_ATTACHMENT_CONTENT_TYPES[extension]
68+
if (!allowedContentTypes) {
69+
throw new Error(`QuickBooks does not support .${extension || 'unknown'} attachment files`)
70+
}
71+
72+
const normalizedContentType = contentType?.split(';', 1)[0]?.trim().toLowerCase()
73+
if (normalizedContentType && !allowedContentTypes.includes(normalizedContentType)) {
74+
throw new Error(
75+
`QuickBooks does not support ${normalizedContentType} content for .${extension} attachments`
76+
)
77+
}
78+
}
79+
3880
export const POST = withRouteHandler(async (request: NextRequest) => {
3981
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
4082
if (!authResult.success || !authResult.userId) {
@@ -57,6 +99,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5799
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
58100
if (denied) return denied
59101
if (userFile.size > QUICKBOOKS_MAX_UPLOAD_BYTES) return uploadSizeError(userFile.size)
102+
let entity: ReturnType<typeof normalizeQuickBooksAttachmentEntity>
103+
try {
104+
entity = normalizeQuickBooksAttachmentEntity(params.entity)
105+
validateQuickBooksAttachmentFile(userFile.name, userFile.type)
106+
} catch (error) {
107+
return attachmentValidationError(error)
108+
}
60109

61110
try {
62111
const downloaded = await downloadServableFileFromStorage(userFile, requestId, logger, {
@@ -67,7 +116,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
67116
}
68117

69118
const contentType = downloaded.contentType || userFile.type || 'application/octet-stream'
70-
const entity = normalizeQuickBooksAttachmentEntity(params.entity)
119+
try {
120+
validateQuickBooksAttachmentFile(userFile.name, contentType)
121+
} catch (error) {
122+
return attachmentValidationError(error)
123+
}
71124
const metadata = {
72125
AttachableRef: [
73126
{

apps/sim/blocks/blocks/quickbooks.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ describe('QuickBooksBlock', () => {
8484
entityId: '17',
8585
file,
8686
})
87+
88+
const attachmentInputs = QuickBooksBlock.subBlocks.filter(
89+
(subBlock) => subBlock.canonicalParamId === 'file'
90+
)
91+
expect(attachmentInputs).toMatchObject([
92+
{ id: 'attachmentFile', mode: 'basic', required: expect.anything() },
93+
{ id: 'attachmentFileRef', mode: 'advanced', required: expect.anything() },
94+
])
8795
})
8896

8997
it('requires full entity payloads for non-simplified deletes', () => {
@@ -99,7 +107,14 @@ describe('QuickBooksBlock', () => {
99107
})
100108
).toEqual({
101109
field: 'deleteEntity',
102-
value: ['Attachable', 'Deposit', 'InventoryAdjustment', 'Transfer'],
110+
value: [
111+
'Attachable',
112+
'CreditCardPayment',
113+
'Deposit',
114+
'InventoryAdjustment',
115+
'RecurringTransaction',
116+
'Transfer',
117+
],
103118
})
104119
expect(
105120
payload.required({
@@ -111,4 +126,43 @@ describe('QuickBooksBlock', () => {
111126
value: ['create_record', 'update_record', 'update_exchange_rate', 'update_preferences'],
112127
})
113128
})
129+
130+
it('exposes only documented operations for newer and locale-specific entities', () => {
131+
const optionsFor = (id: string) => {
132+
const subBlock = QuickBooksBlock.subBlocks.find((candidate) => candidate.id === id)
133+
if (!subBlock || !('options' in subBlock) || !Array.isArray(subBlock.options)) {
134+
throw new Error(`Expected ${id} entity options`)
135+
}
136+
return subBlock.options.map((option) => option.id)
137+
}
138+
139+
expect(optionsFor('listEntity')).toEqual(
140+
expect.arrayContaining([
141+
'CreditCardPayment',
142+
'TaxPayment',
143+
'RecurringTransaction',
144+
'JournalCode',
145+
])
146+
)
147+
expect(optionsFor('getEntity')).toEqual(
148+
expect.arrayContaining([
149+
'CreditCardPayment',
150+
'TaxPayment',
151+
'RecurringTransaction',
152+
'JournalCode',
153+
])
154+
)
155+
expect(optionsFor('createEntity')).toEqual(
156+
expect.arrayContaining(['CreditCardPayment', 'RecurringTransaction', 'JournalCode'])
157+
)
158+
expect(optionsFor('createEntity')).not.toContain('TaxPayment')
159+
expect(optionsFor('updateEntity')).toContain('CreditCardPayment')
160+
expect(optionsFor('updateEntity')).toContain('JournalCode')
161+
expect(optionsFor('updateEntity')).not.toContain('RecurringTransaction')
162+
expect(optionsFor('updateEntity')).not.toContain('TaxPayment')
163+
expect(optionsFor('deleteEntity')).toContain('CreditCardPayment')
164+
expect(optionsFor('deleteEntity')).toContain('RecurringTransaction')
165+
expect(optionsFor('deleteEntity')).not.toContain('JournalCode')
166+
expect(optionsFor('deleteEntity')).not.toContain('TaxPayment')
167+
})
114168
})

apps/sim/blocks/blocks/quickbooks.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AuthMode, IntegrationType } from '@/blocks/types'
55
import { normalizeFileInput } from '@/blocks/utils'
66
import type { QuickBooksResponse } from '@/tools/quickbooks/types'
77
import {
8+
QUICKBOOKS_ATTACHMENT_ENTITIES,
89
QUICKBOOKS_CREATABLE_ENTITIES,
910
QUICKBOOKS_DELETABLE_ENTITIES,
1011
QUICKBOOKS_FULL_DELETE_ENTITIES,
@@ -258,14 +259,25 @@ export const QuickBooksBlock: BlockConfig<QuickBooksResponse> = {
258259
title: 'File',
259260
type: 'file-upload',
260261
canonicalParamId: 'file',
262+
mode: 'basic',
263+
condition: { field: 'operation', value: 'upload_attachment' },
264+
required: { field: 'operation', value: 'upload_attachment' },
265+
},
266+
{
267+
id: 'attachmentFileRef',
268+
title: 'File',
269+
type: 'short-input',
270+
canonicalParamId: 'file',
271+
mode: 'advanced',
272+
placeholder: 'Reference a file from a previous block',
261273
condition: { field: 'operation', value: 'upload_attachment' },
262274
required: { field: 'operation', value: 'upload_attachment' },
263275
},
264276
{
265277
id: 'attachmentEntity',
266278
title: 'Linked Entity Type',
267279
type: 'dropdown',
268-
options: buildEntityOptions(QUICKBOOKS_READABLE_ENTITIES),
280+
options: buildEntityOptions(QUICKBOOKS_ATTACHMENT_ENTITIES),
269281
value: () => 'PurchaseOrder',
270282
condition: { field: 'operation', value: 'upload_attachment' },
271283
required: { field: 'operation', value: 'upload_attachment' },
@@ -313,7 +325,11 @@ export const QuickBooksBlock: BlockConfig<QuickBooksResponse> = {
313325
title: 'Sparse Update',
314326
type: 'switch',
315327
value: () => 'true',
316-
condition: { field: 'operation', value: 'update_record' },
328+
condition: {
329+
field: 'operation',
330+
value: 'update_record',
331+
and: { field: 'updateEntity', value: 'InventoryAdjustment', not: true },
332+
},
317333
mode: 'advanced',
318334
},
319335
{

apps/sim/lib/integrations/integrations.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"updatedAt": "2026-07-29",
2+
"updatedAt": "2026-07-30",
33
"integrations": [
44
{
55
"type": "onepassword",
@@ -14540,7 +14540,7 @@
1454014540
},
1454114541
{
1454214542
"name": "Upload Attachment",
14543-
"description": "Upload and link a file to a QuickBooks transaction or list entity"
14543+
"description": "Upload and link a supported file up to 25 MB to a QuickBooks transaction or item"
1454414544
},
1454514545
{
1454614546
"name": "Get Attachment URL",

0 commit comments

Comments
 (0)