Skip to content

Commit 9601eb3

Browse files
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) <noreply@anthropic.com>
1 parent 8888863 commit 9601eb3

1 file changed

Lines changed: 143 additions & 37 deletions

File tree

src/app/api/quickbooks/sync/sync.service.ts

Lines changed: 143 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { QBPortalConnection } from '@/db/schema/qbPortalConnections'
2424
import { MAX_ATTEMPTS } from '@/constant/sync'
2525
import { captureMessage } from '@sentry/nextjs'
2626
import { AccountTypeObj } from '@/constant/qbConnection'
27+
import { SettingService } from '@/app/api/quickbooks/setting/setting.service'
28+
import { isPortalInBankDepositABTest } from '@/utils/abTesting'
2729
import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error'
2830
import {
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

Comments
 (0)