From 95fd018c2b3de2a7d2b2e35be01d8e5508137ad0 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 20 Apr 2026 16:32:10 +0545 Subject: [PATCH] fix(OUT-3620): correct deposit retry date, status filter, and null validation Post the bank deposit on the PAID log date (not the retry date), filter the PAID sync log lookup to SUCCESS only, and validate expenseAccountRef the same way the expense path does. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/quickbooks/sync/sync.service.ts | 180 ++++++++++++++++---- 1 file changed, 143 insertions(+), 37 deletions(-) diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index 6d1f78aa..0e48702d 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -24,6 +24,8 @@ import { QBPortalConnection } from '@/db/schema/qbPortalConnections' import { MAX_ATTEMPTS } from '@/constant/sync' import { captureMessage } from '@sentry/nextjs' import { AccountTypeObj } from '@/constant/qbConnection' +import { SettingService } from '@/app/api/quickbooks/setting/setting.service' +import { isPortalInBankDepositABTest } from '@/utils/abTesting' import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error' import { getCategory, @@ -237,49 +239,43 @@ export class SyncService extends BaseService { ) { try { CustomLogger.info({ - message: 'syncService#processPaymentSucceededSync | records: ', + message: 'SyncService#processPaymentSucceededSync | record: ', obj: record, }) + + const settingService = new SettingService(this.user) + const setting = await settingService.getOneByPortalId([ + 'absorbedFeeFlag', + 'bankDepositFeeFlag', + ]) + const useBankDepositFlow = + setting?.absorbedFeeFlag && + setting?.bankDepositFeeFlag && + isPortalInBankDepositABTest(this.user.workspaceId) + const intuitApi = new IntuitAPI(qbTokenInfo) const tokenService = new TokenService(this.user) - const assetAccountRef = await tokenService.checkAndUpdateAccountStatus( - AccountTypeObj.Asset, - qbTokenInfo.intuitRealmId, - intuitApi, - qbTokenInfo.assetAccountRef, - ) - const expenseAccountRef = await tokenService.checkAndUpdateAccountStatus( - AccountTypeObj.Expense, - qbTokenInfo.intuitRealmId, - intuitApi, - qbTokenInfo.expenseAccountRef, - ) + const paymentService = new PaymentService(this.user) - const expensePayload = { - PaymentType: 'Cash' as const, - AccountRef: { - value: z.string().parse(assetAccountRef), - }, - DocNumber: record.invoiceNumber || '', - 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 - Line: [ - { - DetailType: 'AccountBasedExpenseLineDetail' as const, - Amount: parseFloat(z.string().parse(record.feeAmount)) / 100, // fee amount is required for payment/expense creation - AccountBasedExpenseLineDetail: { - AccountRef: { - value: z.string().parse(expenseAccountRef), - }, - }, - }, - ], + // Deposit retry requires invoiceNumber to look up the PAID sync log. + // Fall back to expense retry if invoiceNumber is missing (old logs or early failures). + if (useBankDepositFlow && record.invoiceNumber) { + await this.processDepositRetry( + record, + qbTokenInfo, + intuitApi, + tokenService, + paymentService, + ) + } else { + await this.processExpenseRetry( + record, + qbTokenInfo, + intuitApi, + tokenService, + paymentService, + ) } - const paymentService = new PaymentService(this.user) - await paymentService.createExpenseForAbsorbedFees( - expensePayload, - intuitApi, - record.copilotId, - ) } catch (error: unknown) { CustomLogger.error({ message: 'SyncService#processPaymentSucceededSync', @@ -292,6 +288,116 @@ export class SyncService extends BaseService { } } + private async processExpenseRetry( + record: QBSyncLogSelectSchemaType, + qbTokenInfo: IntuitAPITokensType, + intuitApi: IntuitAPI, + tokenService: TokenService, + paymentService: PaymentService, + ) { + const assetAccountRef = await tokenService.checkAndUpdateAccountStatus( + AccountTypeObj.Asset, + qbTokenInfo.intuitRealmId, + intuitApi, + qbTokenInfo.assetAccountRef, + ) + const expenseAccountRef = await tokenService.checkAndUpdateAccountStatus( + AccountTypeObj.Expense, + qbTokenInfo.intuitRealmId, + intuitApi, + qbTokenInfo.expenseAccountRef, + ) + + const expensePayload = { + PaymentType: 'Cash' as const, + AccountRef: { + value: z.string().parse(assetAccountRef), + }, + DocNumber: record.invoiceNumber || '', + 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 + Line: [ + { + DetailType: 'AccountBasedExpenseLineDetail' as const, + Amount: parseFloat(z.string().parse(record.feeAmount)) / 100, // fee amount is required for payment/expense creation + AccountBasedExpenseLineDetail: { + AccountRef: { + value: z.string().parse(expenseAccountRef), + }, + }, + }, + ], + } + await paymentService.createExpenseForAbsorbedFees( + expensePayload, + intuitApi, + record.copilotId, + ) + } + + private async processDepositRetry( + record: QBSyncLogSelectSchemaType, + qbTokenInfo: IntuitAPITokensType, + intuitApi: IntuitAPI, + tokenService: TokenService, + paymentService: PaymentService, + ) { + if (!record.invoiceNumber) { + throw new Error( + `SyncService#processDepositRetry | invoiceNumber missing on sync log ${record.id}`, + ) + } + if (!record.feeAmount) { + throw new Error( + `SyncService#processDepositRetry | feeAmount missing on sync log ${record.id}`, + ) + } + + // Look up the successful PAID sync log to get the QBO Payment ID and gross amount. + // Filter by SUCCESS status so we don't grab an in-flight or failed PAID log whose + // quickbooksId may not yet exist in QBO. + const paidSyncLog = await this.syncLogService.getOne( + and( + eq(QBSyncLog.portalId, this.user.workspaceId), + eq(QBSyncLog.invoiceNumber, record.invoiceNumber), + eq(QBSyncLog.eventType, EventType.PAID), + eq(QBSyncLog.entityType, EntityType.INVOICE), + eq(QBSyncLog.status, LogStatus.SUCCESS), + ) as WhereClause, + ) + + if (!paidSyncLog?.quickbooksId || !paidSyncLog.amount) { + throw new Error( + `SyncService#processDepositRetry | PAID sync log not found or missing data for invoice: ${record.invoiceNumber}`, + ) + } + + const expenseAccountRef = await tokenService.checkAndUpdateAccountStatus( + AccountTypeObj.Expense, + qbTokenInfo.intuitRealmId, + intuitApi, + qbTokenInfo.expenseAccountRef, + ) + + const bankAccountRef = qbTokenInfo.bankAccountRef + if (!bankAccountRef) { + throw new Error( + 'SyncService#processDepositRetry | bankAccountRef is not configured', + ) + } + + await paymentService.createBankDepositForPayment(intuitApi, { + qbPaymentId: paidSyncLog.quickbooksId, + grossAmount: Number(paidSyncLog.amount) / 100, + feeAmount: Number(record.feeAmount) / 100, + bankAccountRef, + expenseAccountRef: z.string().parse(expenseAccountRef), + // Post the deposit on the payment date (from the PAID log), not the fee-retry date. + txnDate: dayjs(paidSyncLog.createdAt).format('YYYY-MM-DD'), + invoiceNumber: record.invoiceNumber, + paymentId: record.copilotId, + }) + } + private async processProductCreate( record: QBSyncLogSelectSchemaType, qbTokenInfo: IntuitAPITokensType,