@@ -24,6 +24,8 @@ import { QBPortalConnection } from '@/db/schema/qbPortalConnections'
2424import { MAX_ATTEMPTS } from '@/constant/sync'
2525import { captureMessage } from '@sentry/nextjs'
2626import { AccountTypeObj } from '@/constant/qbConnection'
27+ import { SettingService } from '@/app/api/quickbooks/setting/setting.service'
28+ import { isPortalInBankDepositABTest } from '@/utils/abTesting'
2729import { ErrorMessageAndCode , getMessageAndCodeFromError } from '@/utils/error'
2830import {
2931 getCategory ,
@@ -237,49 +239,43 @@ export class SyncService extends BaseService {
237239 ) {
238240 try {
239241 CustomLogger . info ( {
240- message : 'syncService #processPaymentSucceededSync | records : ' ,
242+ message : 'SyncService #processPaymentSucceededSync | record : ' ,
241243 obj : record ,
242244 } )
245+
246+ const settingService = new SettingService ( this . user )
247+ const setting = await settingService . getOneByPortalId ( [
248+ 'absorbedFeeFlag' ,
249+ 'bankDepositFeeFlag' ,
250+ ] )
251+ const useBankDepositFlow =
252+ setting ?. absorbedFeeFlag &&
253+ setting ?. bankDepositFeeFlag &&
254+ isPortalInBankDepositABTest ( this . user . workspaceId )
255+
243256 const intuitApi = new IntuitAPI ( qbTokenInfo )
244257 const tokenService = new TokenService ( this . user )
245- const assetAccountRef = await tokenService . checkAndUpdateAccountStatus (
246- AccountTypeObj . Asset ,
247- qbTokenInfo . intuitRealmId ,
248- intuitApi ,
249- qbTokenInfo . assetAccountRef ,
250- )
251- const expenseAccountRef = await tokenService . checkAndUpdateAccountStatus (
252- AccountTypeObj . Expense ,
253- qbTokenInfo . intuitRealmId ,
254- intuitApi ,
255- qbTokenInfo . expenseAccountRef ,
256- )
258+ const paymentService = new PaymentService ( this . user )
257259
258- const expensePayload = {
259- PaymentType : 'Cash' as const ,
260- AccountRef : {
261- value : z . string ( ) . parse ( assetAccountRef ) ,
262- } ,
263- DocNumber : record . invoiceNumber || '' ,
264- TxnDate : dayjs ( record . createdAt ) . format ( 'YYYY-MM-DD' ) , // the date format for due date follows XML Schema standard (YYYY-MM-DD). For more info: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/purchase#the-purchase-object
265- Line : [
266- {
267- DetailType : 'AccountBasedExpenseLineDetail' as const ,
268- Amount : parseFloat ( z . string ( ) . parse ( record . feeAmount ) ) / 100 , // fee amount is required for payment/expense creation
269- AccountBasedExpenseLineDetail : {
270- AccountRef : {
271- value : z . string ( ) . parse ( expenseAccountRef ) ,
272- } ,
273- } ,
274- } ,
275- ] ,
260+ // Deposit retry requires invoiceNumber to look up the PAID sync log.
261+ // Fall back to expense retry if invoiceNumber is missing (old logs or early failures).
262+ if ( useBankDepositFlow && record . invoiceNumber ) {
263+ await this . processDepositRetry (
264+ record ,
265+ qbTokenInfo ,
266+ intuitApi ,
267+ tokenService ,
268+ paymentService ,
269+ )
270+ } else {
271+ await this . processExpenseRetry (
272+ record ,
273+ qbTokenInfo ,
274+ intuitApi ,
275+ tokenService ,
276+ paymentService ,
277+ )
276278 }
277- const paymentService = new PaymentService ( this . user )
278- await paymentService . createExpenseForAbsorbedFees (
279- expensePayload ,
280- intuitApi ,
281- record . copilotId ,
282- )
283279 } catch ( error : unknown ) {
284280 CustomLogger . error ( {
285281 message : 'SyncService#processPaymentSucceededSync' ,
@@ -292,6 +288,116 @@ export class SyncService extends BaseService {
292288 }
293289 }
294290
291+ private async processExpenseRetry (
292+ record : QBSyncLogSelectSchemaType ,
293+ qbTokenInfo : IntuitAPITokensType ,
294+ intuitApi : IntuitAPI ,
295+ tokenService : TokenService ,
296+ paymentService : PaymentService ,
297+ ) {
298+ const assetAccountRef = await tokenService . checkAndUpdateAccountStatus (
299+ AccountTypeObj . Asset ,
300+ qbTokenInfo . intuitRealmId ,
301+ intuitApi ,
302+ qbTokenInfo . assetAccountRef ,
303+ )
304+ const expenseAccountRef = await tokenService . checkAndUpdateAccountStatus (
305+ AccountTypeObj . Expense ,
306+ qbTokenInfo . intuitRealmId ,
307+ intuitApi ,
308+ qbTokenInfo . expenseAccountRef ,
309+ )
310+
311+ const expensePayload = {
312+ PaymentType : 'Cash' as const ,
313+ AccountRef : {
314+ value : z . string ( ) . parse ( assetAccountRef ) ,
315+ } ,
316+ DocNumber : record . invoiceNumber || '' ,
317+ TxnDate : dayjs ( record . createdAt ) . format ( 'YYYY-MM-DD' ) , // the date format for due date follows XML Schema standard (YYYY-MM-DD). For more info: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/purchase#the-purchase-object
318+ Line : [
319+ {
320+ DetailType : 'AccountBasedExpenseLineDetail' as const ,
321+ Amount : parseFloat ( z . string ( ) . parse ( record . feeAmount ) ) / 100 , // fee amount is required for payment/expense creation
322+ AccountBasedExpenseLineDetail : {
323+ AccountRef : {
324+ value : z . string ( ) . parse ( expenseAccountRef ) ,
325+ } ,
326+ } ,
327+ } ,
328+ ] ,
329+ }
330+ await paymentService . createExpenseForAbsorbedFees (
331+ expensePayload ,
332+ intuitApi ,
333+ record . copilotId ,
334+ )
335+ }
336+
337+ private async processDepositRetry (
338+ record : QBSyncLogSelectSchemaType ,
339+ qbTokenInfo : IntuitAPITokensType ,
340+ intuitApi : IntuitAPI ,
341+ tokenService : TokenService ,
342+ paymentService : PaymentService ,
343+ ) {
344+ if ( ! record . invoiceNumber ) {
345+ throw new Error (
346+ `SyncService#processDepositRetry | invoiceNumber missing on sync log ${ record . id } ` ,
347+ )
348+ }
349+ if ( ! record . feeAmount ) {
350+ throw new Error (
351+ `SyncService#processDepositRetry | feeAmount missing on sync log ${ record . id } ` ,
352+ )
353+ }
354+
355+ // Look up the successful PAID sync log to get the QBO Payment ID and gross amount.
356+ // Filter by SUCCESS status so we don't grab an in-flight or failed PAID log whose
357+ // quickbooksId may not yet exist in QBO.
358+ const paidSyncLog = await this . syncLogService . getOne (
359+ and (
360+ eq ( QBSyncLog . portalId , this . user . workspaceId ) ,
361+ eq ( QBSyncLog . invoiceNumber , record . invoiceNumber ) ,
362+ eq ( QBSyncLog . eventType , EventType . PAID ) ,
363+ eq ( QBSyncLog . entityType , EntityType . INVOICE ) ,
364+ eq ( QBSyncLog . status , LogStatus . SUCCESS ) ,
365+ ) as WhereClause ,
366+ )
367+
368+ if ( ! paidSyncLog ?. quickbooksId || ! paidSyncLog . amount ) {
369+ throw new Error (
370+ `SyncService#processDepositRetry | PAID sync log not found or missing data for invoice: ${ record . invoiceNumber } ` ,
371+ )
372+ }
373+
374+ const expenseAccountRef = await tokenService . checkAndUpdateAccountStatus (
375+ AccountTypeObj . Expense ,
376+ qbTokenInfo . intuitRealmId ,
377+ intuitApi ,
378+ qbTokenInfo . expenseAccountRef ,
379+ )
380+
381+ const bankAccountRef = qbTokenInfo . bankAccountRef
382+ if ( ! bankAccountRef ) {
383+ throw new Error (
384+ 'SyncService#processDepositRetry | bankAccountRef is not configured' ,
385+ )
386+ }
387+
388+ await paymentService . createBankDepositForPayment ( intuitApi , {
389+ qbPaymentId : paidSyncLog . quickbooksId ,
390+ grossAmount : Number ( paidSyncLog . amount ) / 100 ,
391+ feeAmount : Number ( record . feeAmount ) / 100 ,
392+ bankAccountRef,
393+ expenseAccountRef : z . string ( ) . parse ( expenseAccountRef ) ,
394+ // Post the deposit on the payment date (from the PAID log), not the fee-retry date.
395+ txnDate : dayjs ( paidSyncLog . createdAt ) . format ( 'YYYY-MM-DD' ) ,
396+ invoiceNumber : record . invoiceNumber ,
397+ paymentId : record . copilotId ,
398+ } )
399+ }
400+
295401 private async processProductCreate (
296402 record : QBSyncLogSelectSchemaType ,
297403 qbTokenInfo : IntuitAPITokensType ,
0 commit comments