Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 143 additions & 37 deletions src/app/api/quickbooks/sync/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
import CustomLogger from '@/utils/logger'
import { QBSyncLog, QBSyncLogSelectSchemaType } from '@/db/schema/qbSyncLogs'
import { z } from 'zod'
import { and, eq, inArray, max } from 'drizzle-orm'

Check warning on line 20 in src/app/api/quickbooks/sync/sync.service.ts

View workflow job for this annotation

GitHub Actions / Run linters

'max' is defined but never used. Allowed unused vars must match /^_/u
import { WhereClause } from '@/type/common'
import { TokenService } from '@/app/api/quickbooks/token/token.service'
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,
Expand Down Expand Up @@ -237,49 +239,43 @@
) {
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',
Expand All @@ -292,6 +288,116 @@
}
}

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,
Expand Down
Loading