@@ -7,11 +7,16 @@ vi.mock('@/lib/core/config/env', () => ({
77
88import {
99 buildQuickBooksCreatePaymentBody ,
10+ buildQuickBooksCreateSalesDocumentBody ,
1011 buildQuickBooksUpdatePaymentBody ,
12+ buildQuickBooksUpdateSalesDocumentBody ,
1113 parseQuickBooksInvoiceAllocations ,
1214 parseQuickBooksSalesLines ,
1315} from '@/tools/quickbooks/sales_utils'
1416import { 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
1621describe ( '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