Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 1 addition & 2 deletions src/app/api/quickbooks/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
getValidQbTokens,
QBReconnectRequiredError,
} from '@/utils/tokenRefresh'
import { after } from 'next/server'

export class AuthService extends BaseService {
async getAuthUrl(
Expand Down Expand Up @@ -169,7 +168,7 @@ export class AuthService extends BaseService {
connectionStatus: ConnectionStatus.SUCCESS,
})

after(async () => {
afterIfAvailable(async () => {
if (existingToken) {
console.info('Not initial process. Starting the re-sync process')
this.user.qbConnection = {
Expand Down
107 changes: 96 additions & 11 deletions src/app/api/quickbooks/sync/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import { captureMessage } from '@sentry/nextjs'
import { AccountTypeObj } from '@/constant/qbConnection'
import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error'
import { getCategory, getShouldRetryForCategory } from '@/utils/synclog'
import { PayoutService } from '@/app/api/quickbooks/payout/payout.service'
import {
MixedPayoutIntentError,
TerminalPayoutError,
getShouldRetryForPayout,
} from '@/app/api/quickbooks/payout/payout.errors'
import { PAYOUT_MIXED_INTENT_CODE } from '@/constant/intuitErrorCode'

export const runtime = 'nodejs'

Expand Down Expand Up @@ -254,6 +261,12 @@ export class SyncService extends BaseService {
)
}

// Batched deposit: the payout books this fee, so there's no expense to create here. Delete the log
if (invoiceSync.isBatchedDeposit) {
await this.syncLogService.deleteQBSyncLog(record.id)
return
}

const intuitApi = new IntuitAPI(qbTokenInfo)
const tokenService = new TokenService(this.user)
const assetAccountRef = await tokenService.checkAndUpdateAccountStatus(
Expand Down Expand Up @@ -307,6 +320,80 @@ export class SyncService extends BaseService {
}
}

private async processPayoutSync(
record: QBSyncLogSelectSchemaType,
qbTokenInfo: IntuitAPITokensType,
) {
const payoutService = new PayoutService(this.user)
try {
// Claim the row before any QBO work: flip FAILED -> PENDING in one
// atomic update. If another resync run (12h cron vs OAuth-reconnect)
// already claimed it, this matches zero rows and we skip — otherwise
// both runs could create a second, undeletable bank deposit.
const claimed = await this.syncLogService.updateQBSyncLog(
{ status: LogStatus.PENDING },
and(
eq(QBSyncLog.id, record.id),
eq(QBSyncLog.status, LogStatus.FAILED),
) as WhereClause,
)
if (!claimed) {
CustomLogger.info({
message:
'SyncService#processPayoutSync | Already claimed by another run, skipping',
obj: { copilotId: record.copilotId },
})
return
}

const payoutRow = await payoutService.getPayoutSync(record.copilotId)
if (!payoutRow) {
// No saved payout data, so we can't rebuild the deposit. Stop trying.
throw new TerminalPayoutError(
`No qb_payout_sync row for payout ${record.copilotId}`,
)
}

const { depositId } = await payoutService.reconcile(
payoutRow,
qbTokenInfo,
{
runIdempotencyCheck: true,
},
)

await this.syncLogService.updateQBSyncLog(
{
status: LogStatus.SUCCESS,
quickbooksId: depositId ?? undefined,
errorMessage: '',
},
eq(QBSyncLog.id, record.id),
)
} catch (error: unknown) {
CustomLogger.error({
message: 'SyncService#processPayoutSync',
obj: { error },
})
const isMixed = error instanceof MixedPayoutIntentError
const errorWithCode = getMessageAndCodeFromError(error)
await this.syncLogService.updateQBSyncLog(
{
status: LogStatus.FAILED,
errorMessage: errorWithCode.message,
errorCode: isMixed
? PAYOUT_MIXED_INTENT_CODE
: errorWithCode.code?.toString(),
shouldRetry: getShouldRetryForPayout(error),
category: isMixed
? FailedRecordCategoryType.OTHERS
: getCategory(errorWithCode),
},
eq(QBSyncLog.id, record.id),
)
}
}

private async processProductCreate(
record: QBSyncLogSelectSchemaType,
qbTokenInfo: IntuitAPITokensType,
Expand Down Expand Up @@ -401,17 +488,6 @@ export class SyncService extends BaseService {
const authService = new AuthService(this.user)

for (const log of logs) {
// TODO: no PAYOUT resync path yet — skip so terminal payout rows don't
// burn attempts to a misleading alert. Auto-recovery is a follow-up.
if (log.entityType === EntityType.PAYOUT) {
CustomLogger.info({
message:
'SyncService#intiateSync | Skipping payout log (no resync path)',
obj: { copilotId: log.copilotId, workspaceId: this.user.workspaceId },
})
continue
}

// check and update attempt for failed logs
const resyncAttemtps = await this.checkAndUpdateAttempt(log)
if (resyncAttemtps.maxAttempts) {
Expand Down Expand Up @@ -475,6 +551,15 @@ export class SyncService extends BaseService {
await this.processProductSync(log, qbTokenInfo, log.eventType)
break

case EntityType.PAYOUT:
if (log.eventType === EventType.SETTLED) {
CustomLogger.info({
message: 'SyncService#intiateSync | Payout re-sync started',
})
await this.processPayoutSync(log, qbTokenInfo)
}
break

default:
CustomLogger.error({
message: 'SyncService#intiateSync | Unknown entity type',
Expand Down
148 changes: 28 additions & 120 deletions src/app/api/quickbooks/webhook/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
import { WebhookEvents } from '@/app/api/core/types/webhook'
import { InvoiceService } from '@/app/api/quickbooks/invoice/invoice.service'
import { PaymentService } from '@/app/api/quickbooks/payment/payment.service'
import {
MixedPayoutIntentError,
getShouldRetryForPayout,
} from '@/app/api/quickbooks/payout/payout.errors'
import { PayoutService } from '@/app/api/quickbooks/payout/payout.service'
import { ProductService } from '@/app/api/quickbooks/product/product.service'
import { SettingService } from '@/app/api/quickbooks/setting/setting.service'
import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service'
Expand All @@ -28,21 +33,14 @@ import {
import { validateAccessToken } from '@/utils/auth'
import { CopilotAPI } from '@/utils/copilotAPI'
import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error'
import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI'
import { IntuitAPITokensType } from '@/utils/intuitAPI'
import CustomLogger from '@/utils/logger'
import { sleep } from '@/utils/sleep'
import { getCategory, getShouldRetryForCategory } from '@/utils/synclog'
import { addSyncBreadcrumb } from '@/utils/sentry'
import { and, eq } from 'drizzle-orm'
import httpStatus from 'http-status'
import { AccountTypeObj } from '@/constant/qbConnection'
import { PAYOUT_MIXED_INTENT_CODE } from '@/constant/intuitErrorCode'
import { TokenService } from '@/app/api/quickbooks/token/token.service'

// A payout that mixes batched and non-batched invoices. Thrown so the single
// FAILED-log write in the catch can tag it with the routable sentinel code
// (a plain APIError would land as "400" and skip the IU notification).
class MixedPayoutIntentError extends Error {}

export class WebhookService extends BaseService {
async handleWebhookEvent(
Expand Down Expand Up @@ -599,6 +597,7 @@ export class WebhookService extends BaseService {
feeAmount: platformFee.toFixed(2),
errorMessage: `No invoice found in invoice sync table for invoice id: ${invoiceId}`,
shouldRetry: true,
invoiceNumber: invoice.number,
})
return
}
Expand Down Expand Up @@ -649,6 +648,7 @@ export class WebhookService extends BaseService {

const payoutId = payout.id
const syncLogService = new SyncLogService(this.user)
const payoutService = new PayoutService(this.user)
const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId)

// Resolve intent before claiming: an all-non-batched payout books nothing,
Expand All @@ -665,6 +665,20 @@ export class WebhookService extends BaseService {
return
}

// Add up the fee here so the payout row, the success log, and the
// failure log can all use it.
const feeCents = lineItems.reduce((sum, line) => sum + line.feeAmount, 0)

// Save the payout details first so a failed attempt can be rebuilt on
// resync. Runs before the claim, so a re-sent payout just updates the row.
const payoutRow = await payoutService.upsertPayoutSync({
payoutId,
lineItems,
netAmount: payout.netAmount,
feeCents,
arrivalDate: payout.arrivalDate,
})

const { claimed } = await syncLogService.claimWebhookEvent({
copilotId: payoutId,
entityType: EntityType.PAYOUT,
Expand All @@ -677,116 +691,11 @@ export class WebhookService extends BaseService {
return
}

// Computed before the try so the FAILED-log path can record the amounts.
const { grossCents, feeCents } = lineItems.reduce(
(acc, line) => {
acc.grossCents += line.grossAmount
acc.feeCents += line.feeAmount
return acc
},
{ grossCents: 0, feeCents: 0 },
)

try {
validateAccessToken(qbTokenInfo)

// v1: refunds unsupported — a negative line means QBO cannot link to a Payment.
if (lineItems.some((line) => line.grossAmount < 0)) {
throw new APIError(
httpStatus.BAD_REQUEST,
`Payout ${payoutId} contains refund lines; batched deposit unsupported in v1`,
)
}

// A negative total fee would drop the fee line and unbalance the
// deposit. Abort instead (fee credits arrive with refund support).
if (feeCents < 0) {
throw new APIError(
httpStatus.BAD_REQUEST,
`Payout ${payoutId} has a negative aggregate fee (${feeCents}); unsupported in v1`,
)
}

if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) {
throw new APIError(
httpStatus.BAD_REQUEST,
`Payout ${payoutId} contains duplicate invoice line items`,
)
}

// A SUCCESS PAID log always has an invoice-sync row (webhookInvoicePaid
// throws otherwise), so a miss here is a missing payment. Fail the payout.
const unresolved = copilotInvoiceIds.filter(
(id) => !paymentIdByInvoice.has(id),
)
if (unresolved.length > 0) {
throw new APIError(
httpStatus.NOT_FOUND,
`Payout ${payoutId}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`,
)
}

// All-non-batched skipped pre-claim; a non-batched invoice here = mixed.
// get() is non-null — every id passed the unresolved check above.
const allBatched = copilotInvoiceIds.every(
(id) => paymentIdByInvoice.get(id)?.isBatchedDeposit,
)
if (!allBatched) {
throw new MixedPayoutIntentError(
`Payout ${payoutId} mixes batched and non-batched invoices; unsupported`,
)
}

if (grossCents - feeCents !== payout.netAmount) {
throw new APIError(
httpStatus.BAD_REQUEST,
`Payout ${payoutId}: deposit total ${grossCents - feeCents} != payout net ${payout.netAmount}`,
)
}

// Fail fast on the free local check before any QBO round-trip.
const bankAccountRef = qbTokenInfo.bankAccountRef
if (!bankAccountRef) {
throw new APIError(
httpStatus.BAD_REQUEST,
`Bank account ref is not configured for portal ${this.user.workspaceId}. Please select a bank account in the QuickBooks integration settings.`,
)
}

const intuitApi = new IntuitAPI(qbTokenInfo)
const tokenService = new TokenService(this.user)
// Reactivates an archived bank account; a deleted one throws.
const verifiedBankAccountRef =
await tokenService.checkAndUpdateAccountStatus(
AccountTypeObj.Bank,
qbTokenInfo.intuitRealmId,
intuitApi,
bankAccountRef,
)
const expenseAccountRef = await tokenService.checkAndUpdateAccountStatus(
AccountTypeObj.Expense,
qbTokenInfo.intuitRealmId,
intuitApi,
qbTokenInfo.expenseAccountRef,
)

const paymentService = new PaymentService(this.user)
const depositId = await paymentService.createBankDepositForPayment(
intuitApi,
{
lines: lineItems.map((line) => ({
qbPaymentId: paymentIdByInvoice.get(line.copilotInvoiceId)
?.paymentId as string,
amount: line.grossAmount / 100,
})),
feeTotal: feeCents / 100,
bankAccountRef: verifiedBankAccountRef,
expenseAccountRef,
txnDate: new Date(payout.arrivalDate * 1000)
.toISOString()
.split('T')[0],
privateNote: `Stripe payout ${payoutId}`,
},
const { depositId } = await payoutService.reconcile(
payoutRow,
qbTokenInfo,
{ runIdempotencyCheck: false },
)

await syncLogService.updateOrCreateQBSyncLog({
Expand All @@ -795,7 +704,7 @@ export class WebhookService extends BaseService {
eventType: EventType.SETTLED,
status: LogStatus.SUCCESS,
copilotId: payoutId,
quickbooksId: depositId,
quickbooksId: depositId ?? undefined,
amount: payout.netAmount.toFixed(2),
feeAmount: feeCents.toFixed(2),
remark: 'Stripe payout batched deposit',
Expand Down Expand Up @@ -835,8 +744,7 @@ export class WebhookService extends BaseService {
errorCode: isMixed
? PAYOUT_MIXED_INTENT_CODE
: errorWithCode.code?.toString(),
// Terminal: no PAYOUT resync path, so retrying only burns attempts.
shouldRetry: false,
shouldRetry: getShouldRetryForPayout(error),
category: isMixed
? FailedRecordCategoryType.OTHERS
: getCategory(errorWithCode),
Expand Down
Loading
Loading