Skip to content

Commit 3a36dd3

Browse files
waleedlatif1claude
andcommitted
fix(quickbooks): stop replace-allocations detaching non-invoice payment links
buildPaymentLines returned only invoice LinkedTxn entries, and the unapplyOmittedInvoices path sent that array as the payment's entire Line collection. Intuit documents Payment Line.LinkedTxn.TxnType as one of Expense, Check, CreditCardCredit, JournalEntry, CreditMemo or Invoice, and an update as "send all the Lines that need to be present MINUS the lines that need to be removed" — so replacing invoice allocations silently detached every applied credit memo, expense, check and journal entry. Non-invoice lines are now carried forward first, in the order QuickBooks returned them, and counted against the payment total. Also aligned with Intuit's documented model: - salesreceiptrequest requires only Line, refundreceiptrequest only DepositToAccountRef and Line; neither lists CustomerRef, so customerId is optional on both receipt paths and CustomerRef is emitted only when given. - Line.Amount, SalesItemLineDetail.Qty and UnitPrice carry no positivity or non-zero constraint, so zero is accepted (finite/2-decimal/safe-range checks unchanged). - Enforce the documented maximum lengths locally: DocNumber 21, MemoRef.value 1000, Line.Description 4000. - Add void_sales_receipt, the documented salesreceipt?operation=update&include=void sparse void. - read_sales_transactions maxResults description now says 1–1000, matching validateQuickBooksPagination. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RB5M2gk7zZgGomnQqr7WRB
1 parent 9a91e4b commit 3a36dd3

6 files changed

Lines changed: 444 additions & 61 deletions

File tree

‎apps/sim/tools/quickbooks/create_refund_receipt.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ export const quickbooksCreateRefundReceiptTool: ToolConfig<
4646
},
4747
customerId: {
4848
type: 'string',
49-
required: true,
49+
required: false,
5050
visibility: 'user-or-llm',
51-
description: 'Customer receiving the refund',
51+
description: 'Customer receiving the refund, omitted for an anonymous refund',
5252
},
5353
lines: {
5454
type: 'json',
@@ -121,7 +121,10 @@ export const quickbooksCreateRefundReceiptTool: ToolConfig<
121121
method: 'POST',
122122
headers: (params) => getQuickBooksToolHeaders(params.accessToken, 'application/json'),
123123
body: (params) =>
124-
buildQuickBooksCreateSalesDocumentBody(params, { requireDepositAccount: true }),
124+
buildQuickBooksCreateSalesDocumentBody(params, {
125+
requireDepositAccount: true,
126+
customerOptional: true,
127+
}),
125128
retry: { enabled: false },
126129
},
127130
transformResponse: (response) =>

‎apps/sim/tools/quickbooks/create_sales_receipt.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ export const quickbooksCreateSalesReceiptTool: ToolConfig<
4646
},
4747
customerId: {
4848
type: 'string',
49-
required: true,
49+
required: false,
5050
visibility: 'user-or-llm',
51-
description: 'Customer for the sales receipt',
51+
description: 'Customer for the sales receipt, omitted for an anonymous sale',
5252
},
5353
lines: {
5454
type: 'json',
@@ -120,7 +120,7 @@ export const quickbooksCreateSalesReceiptTool: ToolConfig<
120120
).toString(),
121121
method: 'POST',
122122
headers: (params) => getQuickBooksToolHeaders(params.accessToken, 'application/json'),
123-
body: (params) => buildQuickBooksCreateSalesDocumentBody(params),
123+
body: (params) => buildQuickBooksCreateSalesDocumentBody(params, { customerOptional: true }),
124124
retry: { enabled: false },
125125
},
126126
transformResponse: (response) =>

‎apps/sim/tools/quickbooks/read_sales_transactions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const quickbooksReadSalesTransactionsTool: ToolConfig<
7575
required: false,
7676
visibility: 'user-or-llm',
7777
default: 25,
78-
description: 'Number of list records to request (1–100)',
78+
description: 'Number of list records to request (1–1000)',
7979
},
8080
startDate: {
8181
type: 'string',

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

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ vi.mock('@/lib/core/config/env', () => ({
77

88
import {
99
buildQuickBooksCreatePaymentBody,
10+
buildQuickBooksCreateSalesDocumentBody,
1011
buildQuickBooksUpdatePaymentBody,
12+
buildQuickBooksUpdateSalesDocumentBody,
1113
parseQuickBooksInvoiceAllocations,
1214
parseQuickBooksSalesLines,
1315
} from '@/tools/quickbooks/sales_utils'
1416
import { getQuickBooksOperationError } from '@/tools/quickbooks/utils'
17+
import { quickbooksVoidCustomerPaymentTool } from '@/tools/quickbooks/void_customer_payment'
18+
import { quickbooksVoidInvoiceTool } from '@/tools/quickbooks/void_invoice'
19+
import { quickbooksVoidSalesReceiptTool } from '@/tools/quickbooks/void_sales_receipt'
1520

1621
describe('QuickBooks sales monetary validation', () => {
1722
it.each([
@@ -274,3 +279,199 @@ describe('QuickBooks customer payment allocations', () => {
274279
})
275280
})
276281
})
282+
283+
describe('QuickBooks payment allocation replacement', () => {
284+
const paymentWithCreditMemo = {
285+
Id: 'payment-1',
286+
TotalAmt: 30,
287+
Line: [
288+
{ Amount: 10, LinkedTxn: [{ TxnId: 'credit-memo-1', TxnType: 'CreditMemo' }] },
289+
{ Amount: 5, LinkedTxn: [{ TxnId: 'expense-1', TxnType: 'Expense' }] },
290+
{ Amount: 15, LinkedTxn: [{ TxnId: 'invoice-1', TxnType: 'Invoice' }] },
291+
],
292+
}
293+
294+
it('keeps every non-invoice linked line when replacing invoice allocations', () => {
295+
expect(
296+
buildQuickBooksUpdatePaymentBody(
297+
{
298+
accessToken: 'token',
299+
realmId: 'realm',
300+
paymentId: 'payment-1',
301+
syncToken: '0',
302+
invoiceAllocations: [{ invoiceId: 'invoice-2', amount: 15 }],
303+
unapplyOmittedInvoices: true,
304+
},
305+
paymentWithCreditMemo
306+
).Line
307+
).toEqual([
308+
{ Amount: 10, LinkedTxn: [{ TxnId: 'credit-memo-1', TxnType: 'CreditMemo' }] },
309+
{ Amount: 5, LinkedTxn: [{ TxnId: 'expense-1', TxnType: 'Expense' }] },
310+
{ Amount: 15, LinkedTxn: [{ TxnId: 'invoice-2', TxnType: 'Invoice' }] },
311+
])
312+
})
313+
314+
it('counts carried-forward lines against the payment total', () => {
315+
expect(() =>
316+
buildQuickBooksUpdatePaymentBody(
317+
{
318+
accessToken: 'token',
319+
realmId: 'realm',
320+
paymentId: 'payment-1',
321+
syncToken: '0',
322+
invoiceAllocations: [{ invoiceId: 'invoice-2', amount: 16 }],
323+
unapplyOmittedInvoices: true,
324+
},
325+
paymentWithCreditMemo
326+
)
327+
).toThrow('Invoice allocation amounts cannot exceed totalAmount')
328+
})
329+
})
330+
331+
describe('QuickBooks sales line numeric bounds', () => {
332+
it('accepts a zero amount, quantity, and unit price', () => {
333+
expect(
334+
parseQuickBooksSalesLines([
335+
{ lineType: 'item', amount: 0, itemId: 'item-1', quantity: 0, unitPrice: 0 },
336+
])
337+
).toEqual([
338+
{
339+
lineType: 'item',
340+
amount: 0,
341+
itemId: 'item-1',
342+
description: undefined,
343+
quantity: 0,
344+
unitPrice: 0,
345+
serviceDate: undefined,
346+
},
347+
])
348+
})
349+
})
350+
351+
describe('QuickBooks sales document bodies', () => {
352+
const line = { lineType: 'item' as const, amount: 10, itemId: 'item-1' }
353+
354+
it('emits the documented detail types for item and description lines', () => {
355+
expect(
356+
buildQuickBooksCreateSalesDocumentBody({
357+
accessToken: 'token',
358+
realmId: 'realm',
359+
customerId: 'customer-1',
360+
lines: [line, { lineType: 'description', description: 'Thanks' }],
361+
}).Line
362+
).toEqual([
363+
{
364+
Amount: 10,
365+
DetailType: 'SalesItemLineDetail',
366+
SalesItemLineDetail: { ItemRef: { value: 'item-1' } },
367+
},
368+
{ DetailType: 'DescriptionOnly', Description: 'Thanks', DescriptionLineDetail: {} },
369+
])
370+
})
371+
372+
it('omits CustomerRef on a receipt created without a customer', () => {
373+
expect(
374+
buildQuickBooksCreateSalesDocumentBody(
375+
{ accessToken: 'token', realmId: 'realm', customerId: '', lines: [line] },
376+
{ customerOptional: true }
377+
)
378+
).not.toHaveProperty('CustomerRef')
379+
})
380+
381+
it('still requires a customer on the documents that list CustomerRef', () => {
382+
expect(() =>
383+
buildQuickBooksCreateSalesDocumentBody({
384+
accessToken: 'token',
385+
realmId: 'realm',
386+
customerId: '',
387+
lines: [line],
388+
})
389+
).toThrow()
390+
})
391+
392+
it('rejects values past the documented QuickBooks maximum lengths', () => {
393+
expect(() =>
394+
buildQuickBooksCreateSalesDocumentBody({
395+
accessToken: 'token',
396+
realmId: 'realm',
397+
customerId: 'customer-1',
398+
lines: [line],
399+
documentNumber: 'D'.repeat(22),
400+
})
401+
).toThrow('documentNumber cannot exceed 21 characters')
402+
403+
expect(() =>
404+
buildQuickBooksCreateSalesDocumentBody({
405+
accessToken: 'token',
406+
realmId: 'realm',
407+
customerId: 'customer-1',
408+
lines: [line],
409+
customerMemo: 'M'.repeat(1001),
410+
})
411+
).toThrow('customerMemo cannot exceed 1000 characters')
412+
413+
expect(() => parseQuickBooksSalesLines([{ ...line, description: 'x'.repeat(4001) }])).toThrow(
414+
'lines[0].description cannot exceed 4000 characters'
415+
)
416+
})
417+
418+
it('builds a sparse update body carrying the identifiers and the patch', () => {
419+
expect(
420+
buildQuickBooksUpdateSalesDocumentBody({
421+
accessToken: 'token',
422+
realmId: 'realm',
423+
transactionId: 'invoice-1',
424+
syncToken: '3',
425+
privateNote: 'Updated',
426+
})
427+
).toEqual({
428+
Id: 'invoice-1',
429+
SyncToken: '3',
430+
sparse: true,
431+
PrivateNote: 'Updated',
432+
})
433+
})
434+
})
435+
436+
describe('QuickBooks void request shapes', () => {
437+
const params = {
438+
accessToken: 'token',
439+
realmId: '123',
440+
quickBooksEnvironment: 'sandbox' as const,
441+
transactionId: 'txn-1',
442+
syncToken: '4',
443+
confirmVoid: true,
444+
}
445+
446+
it('voids an invoice through operation=void with no sparse flag', () => {
447+
const url = new URL(quickbooksVoidInvoiceTool.request.url(params))
448+
expect(url.pathname).toContain('/invoice')
449+
expect(url.searchParams.get('operation')).toBe('void')
450+
expect(url.searchParams.get('include')).toBeNull()
451+
expect(quickbooksVoidInvoiceTool.request.body?.(params)).toEqual({
452+
Id: 'txn-1',
453+
SyncToken: '4',
454+
})
455+
})
456+
457+
it.each([
458+
['payment', quickbooksVoidCustomerPaymentTool],
459+
['salesreceipt', quickbooksVoidSalesReceiptTool],
460+
])('voids %s through operation=update&include=void with sparse', (resource, tool) => {
461+
const url = new URL(tool.request.url(params))
462+
expect(url.pathname).toContain(`/${resource}`)
463+
expect(url.searchParams.get('operation')).toBe('update')
464+
expect(url.searchParams.get('include')).toBe('void')
465+
expect(tool.request.body?.(params)).toEqual({
466+
Id: 'txn-1',
467+
SyncToken: '4',
468+
sparse: true,
469+
})
470+
})
471+
472+
it('refuses to void a sales receipt without explicit confirmation', () => {
473+
expect(() =>
474+
quickbooksVoidSalesReceiptTool.request.body?.({ ...params, confirmVoid: false })
475+
).toThrow('Confirm void before voiding the sales receipt')
476+
})
477+
})

0 commit comments

Comments
 (0)