Skip to content

Commit de78c49

Browse files
committed
fix(quickbooks): correct API conformance, prevent write data loss, shrink shared-logic surface
Validation of the integration against Intuit's published attribute tables (static.developer.intuit.com/JSONObjects/*, the payload behind the docs SPA) surfaced defects that write wrong data to customers' books, plus shared-module changes broader than this integration needs. Data integrity: - update_customer_payment now reads the payment and merges allocations. QuickBooks applies payment lines ALL-or-NONE, so sending a subset silently unapplied every omitted invoice. Destructive replacement stays reachable via a user-only unapplyOmittedInvoices flag. Also validates the merged total, not just the caller's allocations. - create_item required ExpenseAccountRef (required for Service and NonInventory) as optional while mandating IncomeAccountRef (not required for NonInventory). - create_employee required DisplayName, which QuickBooks derives and treats as read-only under Payroll, while leaving the real at-least-one GivenName/FamilyName rule unchecked. Both tools threw a raw TypeError on their schema-minimal call. - create_purchase sent PaymentRefNum, absent from the Purchase entity; QuickBooks discards unknown fields silently, so check numbers vanished. Now DocNumber. - A Fault nested in QueryResponse was reported as an empty result set. Detected in parseQuickBooksJson so every response path is covered. - assertQuickBooksSparseUpdate never checked sparse despite its name. - Bill header-level LinkedTxn omitted the required TxnLineId and duplicated the line-level links; DescriptionOnly lines omitted DescriptionLineDetail. - Line amounts were positive-only, making discounts, returns and credits unrepresentable, and bare Number() silently coerced true and [5] onto the wire. - Phantom report parameters removed; aging method and period split so each report only offers the control it accepts. Safety and boundaries: - confirmVoid and confirmPosting moved to user-only, so an agent can no longer supply its own approval for voids and journal postings. - Split the runtime-free helpers out of tools/quickbooks/utils.ts. The block was the only one in the repo dragging error-extractors, client.ts, microsoft_excel/utils and stream-limits into the client bundle. - Attachment routes: 20 MB cap per Intuit's limit, outbound timeouts, extension preserved through filename truncation, Unicode filenames, wider MIME acceptance. - The two download routes merged into one; extracted storeToolOutputFile. Shared logic reduced to what this integration needs: - redaction.ts back to a bypass-set entry for SyncToken; every origin/staging assertion retained. - refreshOAuthToken read the body before the ok check, so a token response over 64 KiB became a spurious failure for every provider. Provider error text is restored to logs, redacted and truncated, and kept out of the caller message. - maxResponseBytes clamped to the global ceiling. - generate-docs is deterministic again; its output no longer depends on what is already on disk. The generic redaction engine, the serializer singleton-canonical fix and the visibility-based docs filter are deferred to their own PRs. Sparse-update semantics for ten write tools remain unverified against a live sandbox; apps/sim/scripts/quickbooks-sparse-probe.ts settles it.
1 parent 0c3de74 commit de78c49

70 files changed

Lines changed: 3342 additions & 6792 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Manage QuickBooks Online company, transactions, reports, emails, PD
55

66
import { BlockInfoCard } from "@/components/ui/block-info-card"
77

8-
<BlockInfoCard
8+
<BlockInfoCard
99
type="quickbooks"
1010
color="#2CA01C"
1111
/>
@@ -60,7 +60,10 @@ Get information about the connected QuickBooks Online company
6060
|`CompanyStartDate` | string | Company start date |
6161
|`Country` | string | Company country code |
6262
|`FiscalYearStartMonth` | string | Fiscal year starting month |
63-
|`DefaultTimeZone` | string | Company default time zone |
63+
|`SupportedLanguages` | string | Comma-separated list of languages supported by the company |
64+
|`EmployerId` | string | Employer Identification Number, when defined in company settings |
65+
|`domain` | string | Originating Intuit domain |
66+
|`sparse` | boolean | Whether QuickBooks returned a partial representation |
6467
|`NameValue` | array | QuickBooks company settings represented as name/value entries |
6568
|`MetaData` | json | CompanyInfo creation and update timestamps |
6669
|`CreateTime` | string | Entity creation timestamp |
@@ -309,9 +312,9 @@ Create a non-payroll employee profile in the connected QuickBooks Online company
309312

310313
| Parameter | Type | Required | Description |
311314
| --------- | ---- | -------- | ----------- |
312-
| `displayName` | string | Yes | Unique employee display name |
313-
| `givenName` | string | No | Employee given name |
314-
| `familyName` | string | No | Employee family name |
315+
| `displayName` | string | No | Unique employee display name. When omitted QuickBooks derives it from the supplied name components, and it is read-only when QuickBooks Payroll is enabled |
316+
| `givenName` | string | No | Employee given name. At least one of givenName or familyName is required |
317+
| `familyName` | string | No | Employee family name. At least one of givenName or familyName is required |
315318
| `primaryEmail` | string | No | Employee primary email address |
316319
| `primaryPhone` | string | No | Employee primary phone number |
317320
| `primaryAddress` | json | No | Employee primary address |
@@ -499,12 +502,12 @@ Create a Service or Non-inventory item in QuickBooks Online
499502
| --------- | ---- | -------- | ----------- |
500503
| `name` | string | Yes | Unique item name |
501504
| `itemType` | string | Yes | Writable item type: service or non_inventory |
502-
| `incomeAccountId` | string | Yes | Income account ID used when the item is sold |
505+
| `incomeAccountId` | string | No | Sales of Product Income account ID recording proceeds from the sale. Required for Service items, optional for Non-inventory items and for France locales |
503506
| `description` | string | No | Sales description |
504507
| `unitPrice` | number | No | Sales price per unit |
505508
| `purchaseDescription` | string | No | Purchase description |
506509
| `purchaseCost` | number | No | Purchase cost per unit |
507-
| `expenseAccountId` | string | No | Expense account ID used when the item is purchased |
510+
| `expenseAccountId` | string | Yes | Cost of Goods Sold account ID used to pay the vendor for this item. Required for both Service and Non-inventory items, except in France locales where it is optional |
508511
| `taxable` | boolean | No | Whether the item is taxable |
509512
| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters |
510513

@@ -764,7 +767,7 @@ Sparse-update an estimate using its current sync token
764767
| `transactionId` | string | Yes | Estimate ID to update |
765768
| `syncToken` | string | Yes | Current estimate sync token |
766769
| `customerId` | string | No | Replacement customer ID |
767-
| `lines` | json | No | Replacement bounded estimate lines |
770+
| `lines` | json | No | Complete replacement set of estimate lines: any existing line omitted here is deleted from the estimate |
768771
| `transactionDate` | string | No | Replacement estimate date in YYYY-MM-DD format |
769772
| `expirationDate` | string | No | Replacement expiration date in YYYY-MM-DD format |
770773
| `documentNumber` | string | No | Replacement estimate number |
@@ -879,7 +882,7 @@ Sparse-update an invoice using its current sync token
879882
| `transactionId` | string | Yes | Invoice ID to update |
880883
| `syncToken` | string | Yes | Current invoice sync token |
881884
| `customerId` | string | No | Replacement customer ID |
882-
| `lines` | json | No | Replacement bounded invoice lines |
885+
| `lines` | json | No | Complete replacement set of invoice lines: any existing line omitted here is deleted from the invoice |
883886
| `transactionDate` | string | No | Replacement invoice date in YYYY-MM-DD format |
884887
| `dueDate` | string | No | Replacement due date in YYYY-MM-DD format |
885888
| `documentNumber` | string | No | Replacement invoice number |
@@ -1049,7 +1052,7 @@ Sparse-update a sales receipt using its current sync token
10491052
| `transactionId` | string | Yes | Sales receipt ID to update |
10501053
| `syncToken` | string | Yes | Current sales receipt sync token |
10511054
| `customerId` | string | No | Replacement customer ID |
1052-
| `lines` | json | No | Replacement bounded sales receipt lines |
1055+
| `lines` | json | No | Complete replacement set of sales receipt lines: any existing line omitted here is deleted from the sales receipt |
10531056
| `transactionDate` | string | No | Replacement receipt date in YYYY-MM-DD format |
10541057
| `documentNumber` | string | No | Replacement sales receipt number |
10551058
| `privateNote` | string | No | Replacement internal note |
@@ -1173,7 +1176,8 @@ Sparse-update a customer payment using its current sync token
11731176
| `paymentReferenceNumber` | string | No | Replacement payment reference number |
11741177
| `paymentMethodId` | string | No | Replacement payment method ID |
11751178
| `depositAccountId` | string | No | Replacement deposit account ID |
1176-
| `invoiceAllocations` | json | No | Replacement bounded invoice allocations |
1179+
| `invoiceAllocations` | json | No | Bounded invoice allocations to apply. Each entry sets the amount applied to that invoice; invoices already applied on the payment and not listed here keep their current amounts |
1180+
| `unapplyOmittedInvoices` | boolean | No | Replace the payment allocations outright. Every invoice not listed in invoiceAllocations is UNAPPLIED and returns to open |
11771181

11781182
#### Output
11791183

@@ -1335,7 +1339,7 @@ Sparse-update a credit memo using its current sync token
13351339
| `transactionId` | string | Yes | Credit memo ID to update |
13361340
| `syncToken` | string | Yes | Current credit memo sync token |
13371341
| `customerId` | string | No | Replacement customer ID |
1338-
| `lines` | json | No | Replacement bounded credit memo lines |
1342+
| `lines` | json | No | Complete replacement set of credit memo lines: any existing line omitted here is deleted from the credit memo |
13391343
| `transactionDate` | string | No | Replacement credit memo date in YYYY-MM-DD format |
13401344
| `documentNumber` | string | No | Replacement credit memo number |
13411345
| `privateNote` | string | No | Replacement internal note |
@@ -1451,7 +1455,7 @@ Sparse-update a refund receipt using its current sync token
14511455
| `transactionId` | string | Yes | Refund receipt ID to update |
14521456
| `syncToken` | string | Yes | Current refund receipt sync token |
14531457
| `customerId` | string | No | Replacement customer ID |
1454-
| `lines` | json | No | Replacement bounded refund lines |
1458+
| `lines` | json | No | Complete replacement set of refund receipt lines: any existing line omitted here is deleted from the refund receipt |
14551459
| `transactionDate` | string | No | Replacement refund date in YYYY-MM-DD format |
14561460
| `documentNumber` | string | No | Replacement refund receipt number |
14571461
| `privateNote` | string | No | Replacement internal note |
@@ -2234,7 +2238,7 @@ Record a cash, check, or credit-card purchase with bounded expense lines
22342238
| `lines` | json | Yes | Bounded account-based or item-based expense lines |
22352239
| `vendorId` | string | No | Optional vendor payee ID |
22362240
| `transactionDate` | string | No | Purchase date in YYYY-MM-DD format |
2237-
| `paymentReference` | string | No | Optional payment reference number |
2241+
| `paymentReference` | string | No | Optional transaction reference number, such as a check number, sent as the purchase DocNumber |
22382242
| `privateNote` | string | No | Internal purchase note |
22392243
| `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters |
22402244

@@ -2308,7 +2312,7 @@ Sparse-update purchase header fields without changing lines or payment accounts
23082312
| `currentPaymentType` | string | Yes | Current purchase payment type, re-sent unchanged because QuickBooks requires it for sparse updates |
23092313
| `vendorId` | string | No | Replacement vendor payee ID |
23102314
| `transactionDate` | string | No | Replacement purchase date in YYYY-MM-DD format |
2311-
| `paymentReference` | string | No | Replacement payment reference number |
2315+
| `paymentReference` | string | No | Replacement transaction reference number, such as a check number, sent as the purchase DocNumber |
23122316
| `privateNote` | string | No | Replacement internal note |
23132317

23142318
#### Output
@@ -2913,3 +2917,5 @@ Download a QuickBooks file attachment through its short-lived URL
29132917
| `mimeType` | string | Downloaded file MIME type |
29142918
| `size` | number | Downloaded file size in bytes |
29152919
| `attachmentId` | string | Downloaded QuickBooks attachment ID |
2920+
2921+

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
1010
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
1111
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
12-
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
1312
import { assertToolFileAccess } from '@/app/api/files/authorization'
1413
import { buildQuickBooksCompanyUrl, buildQuickBooksHeaders } from '@/tools/quickbooks/client'
1514
import {
15+
assertQuickBooksAttachmentExtension,
1616
assertSingleQuickBooksFile,
1717
buildQuickBooksAttachableMetadata,
1818
getQuickBooksDocumentError,
1919
parseQuickBooksAttachableResponse,
20+
QUICKBOOKS_DOCUMENT_METADATA_TIMEOUT_MS,
21+
QUICKBOOKS_DOCUMENT_TRANSFER_TIMEOUT_MS,
22+
QUICKBOOKS_MAX_ATTACHMENT_BYTES,
23+
quickBooksDocumentSignal,
2024
sanitizeQuickBooksFileName,
2125
validateQuickBooksAttachmentFileType,
2226
} from '@/tools/quickbooks/documents_utils'
@@ -67,21 +71,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6771
'Content-Type': 'application/json',
6872
},
6973
body: JSON.stringify(metadata),
70-
signal: request.signal,
74+
signal: quickBooksDocumentSignal(request.signal, QUICKBOOKS_DOCUMENT_METADATA_TIMEOUT_MS),
7175
})
7276
} else {
7377
request.signal.throwIfAborted()
7478
const rawFile = assertSingleQuickBooksFile(data.file ?? undefined)
7579
const files = processFilesToUserFiles([rawFile], requestId, logger)
7680
if (files.length !== 1) throw new Error('Exactly one valid file is required')
7781
const file = files[0]
78-
assertKnownSizeWithinLimit(file.size, MAX_FILE_SIZE, 'QuickBooks attachment file')
82+
assertKnownSizeWithinLimit(
83+
file.size,
84+
QUICKBOOKS_MAX_ATTACHMENT_BYTES,
85+
'QuickBooks attachment file'
86+
)
87+
const resolvedName = sanitizeQuickBooksFileName(data.fileName ?? undefined, file.name)
88+
assertQuickBooksAttachmentExtension(resolvedName)
7989
const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger)
8090
if (denied) return denied
8191
let downloaded: Awaited<ReturnType<typeof downloadServableFileFromStorage>>
8292
try {
8393
downloaded = await downloadServableFileFromStorage(file, requestId, logger, {
84-
maxBytes: MAX_FILE_SIZE,
94+
maxBytes: QUICKBOOKS_MAX_ATTACHMENT_BYTES,
8595
signal: request.signal,
8696
})
8797
} catch (error) {
@@ -92,12 +102,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
92102
request.signal.throwIfAborted()
93103
assertKnownSizeWithinLimit(
94104
downloaded.buffer.length,
95-
MAX_FILE_SIZE,
105+
QUICKBOOKS_MAX_ATTACHMENT_BYTES,
96106
'QuickBooks attachment file'
97107
)
98108
if (downloaded.buffer.length === 0)
99109
throw new Error('QuickBooks attachment file cannot be empty')
100-
const resolvedName = sanitizeQuickBooksFileName(data.fileName ?? undefined, file.name)
101110
const storedMime = (downloaded.contentType || file.type || '')
102111
.split(';', 1)[0]
103112
.trim()
@@ -137,7 +146,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
137146
method: 'POST',
138147
headers: buildQuickBooksHeaders(data.accessToken),
139148
body: formData,
140-
signal: request.signal,
149+
signal: quickBooksDocumentSignal(request.signal, QUICKBOOKS_DOCUMENT_TRANSFER_TIMEOUT_MS),
141150
})
142151
}
143152

0 commit comments

Comments
 (0)