Skip to content

Commit 89b6a60

Browse files
committed
Merge branch 'fix/qb-sales' into integration/quickbooks-doc-alignment
2 parents 14bf9fc + 3a36dd3 commit 89b6a60

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)