From 535bab78d8f49f635edcbf240fcb294dc4c9ac71 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 31 Jul 2026 12:37:12 +0545 Subject: [PATCH 1/2] feat(OUT-4005): reconcile payouts into batched deposits via webhook and resync Webhook delegates to PayoutService (saving the payout row before claiming). Resync claims the row FAILED->PENDING before work so overlapping runs can't double-deposit, and skips the absorbed-fee expense for batched invoices. Routes token-exchange resync through afterIfAvailable to keep next/server out of the service graph. Co-Authored-By: Claude Opus 4.8 --- src/app/api/quickbooks/auth/auth.service.ts | 3 +- src/app/api/quickbooks/sync/sync.service.ts | 107 ++++++++++-- .../api/quickbooks/webhook/webhook.service.ts | 148 +++------------- test/helpers/seed.ts | 39 +++++ .../bankAccountDeleted.test.ts | 3 +- .../transientRetryable.test.ts | 71 ++++++++ .../unresolvedLine.test.ts | 4 +- .../quickbooks/payoutResync/resync.test.ts | 161 ++++++++++++++++++ 8 files changed, 400 insertions(+), 136 deletions(-) create mode 100644 test/integration/quickbooks/payoutReconciliation/transientRetryable.test.ts create mode 100644 test/integration/quickbooks/payoutResync/resync.test.ts diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index 404fa8a9..c7055d79 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -27,7 +27,6 @@ import { getValidQbTokens, QBReconnectRequiredError, } from '@/utils/tokenRefresh' -import { after } from 'next/server' export class AuthService extends BaseService { async getAuthUrl( @@ -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 = { diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index e83746e2..0b3b430c 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -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' @@ -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( @@ -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, @@ -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) { @@ -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', diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index f6ce5a35..257b1766 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -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' @@ -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( @@ -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 } @@ -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, @@ -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, @@ -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({ @@ -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', @@ -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), diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index 94fa2e1e..7503e7d7 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -10,6 +10,7 @@ import { QBSetting, QBSettingCreateSchema } from '@/db/schema/qbSettings' import { QBCustomers } from '@/db/schema/qbCustomers' import { QBInvoiceSync } from '@/db/schema/qbInvoiceSync' import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { QBPayoutSync } from '@/db/schema/qbPayoutSync' import { InvoiceStatus } from '@/app/api/core/types/invoice' import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' @@ -219,3 +220,41 @@ export async function seedPaidInvoiceForPayout(opts: { quickbooksId: opts.paymentId, }) } + +// A failed, retryable payout sync log plus its qb_payout_sync row — +// the state the resync cron picks up. +export async function seedFailedPayout(opts: { + payoutId: string + lineItems: Array<{ + copilotInvoiceId: string + grossAmount: number + feeAmount: number + }> + netAmount: number + feeCents: number + arrivalDate: number + qbDepositId?: string + errorMessage?: string +}) { + await db.insert(QBPayoutSync).values({ + portalId: TEST_PORTAL_ID, + payoutId: opts.payoutId, + lineItems: opts.lineItems, + netAmount: opts.netAmount, + feeAmount: opts.feeCents, + arrivalDate: opts.arrivalDate, + qbDepositId: opts.qbDepositId ?? null, + }) + await db.insert(QBSyncLog).values({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + copilotId: opts.payoutId, + // Cents-as-string, matching what the webhook writes for a payout log. + amount: opts.netAmount.toFixed(2), + feeAmount: opts.feeCents.toFixed(2), + errorMessage: opts.errorMessage ?? 'QuickBooks timed out', + shouldRetry: true, + }) +} diff --git a/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts index a8856d35..acef9ca9 100644 --- a/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts @@ -73,7 +73,8 @@ describe('payout — configured bank account no longer exists in QuickBooks', () entityType: EntityType.PAYOUT, eventType: EventType.SETTLED, status: LogStatus.FAILED, - shouldRetry: false, + // Not permanent: it works again once the user picks a bank account. + shouldRetry: true, }) // Pins the abort to restoreAccountRef's Bank-type throw specifically — // a deleted bank account is never auto-restored/created. diff --git a/test/integration/quickbooks/payoutReconciliation/transientRetryable.test.ts b/test/integration/quickbooks/payoutReconciliation/transientRetryable.test.ts new file mode 100644 index 00000000..e4507f07 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/transientRetryable.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { createMockIntuitAPI } from '@test/helpers/mocks' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout reconciliation — transient failure', () => { + // Registers module mocks; not read directly — this test asserts on + // qb_sync_logs / qb_payout_sync instead of QBO call args. + setupPaymentSucceededTest(() => ({ + intuit: createMockIntuitAPI({ + createDeposit: vi + .fn() + .mockRejectedValue(new Error('QuickBooks timed out')), + }), + })) + + it('marks a QBO write failure retryable and keeps the payout context', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + const res = await postWebhook(payoutPayload) + expect(res.status).toBe(200) + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + shouldRetry: true, + }) + expect(logs[0].errorMessage).toContain('QuickBooks timed out') + + const payoutRow = await db.query.QBPayoutSync.findFirst() + expect(payoutRow?.payoutId).toBe('po_test_1') + expect(payoutRow?.qbDepositId).toBeNull() + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts index a4474796..50f1d568 100644 --- a/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts @@ -56,8 +56,8 @@ describe('payout — one invoice has no PAID sync log', () => { entityType: EntityType.PAYOUT, eventType: EventType.SETTLED, status: LogStatus.FAILED, - // Payout FAILED rows are terminal by design — never retryable. - shouldRetry: false, + // The invoice.paid event may not have saved yet, so retry. + shouldRetry: true, }) // Pins the abort to the unresolved-line guard specifically — not the // refund guard, the sum-mismatch guard, or the bankAccountRef guard. diff --git a/test/integration/quickbooks/payoutResync/resync.test.ts b/test/integration/quickbooks/payoutResync/resync.test.ts new file mode 100644 index 00000000..7f1601d5 --- /dev/null +++ b/test/integration/quickbooks/payoutResync/resync.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { LogStatus } from '@/app/api/core/types/log' +import User from '@/app/api/core/models/User.model' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + seedFailedPayout, + TEST_PORTAL_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' + +const user = { workspaceId: TEST_PORTAL_ID } as User +const lineItems = [ + { copilotInvoiceId: 'inv-cop-0001', grossAmount: 20000, feeAmount: 375 }, + { copilotInvoiceId: 'inv-cop-0002', grossAmount: 15000, feeAmount: 200 }, +] + +// Dynamic (not top-level) import: SyncService's graph pulls AuthService, +// which imports `next/server`'s `after`. Importing it at module-collection +// time corrupts NTARH's AsyncLocalStorage for sibling postWebhook-based +// test files sharing this worker (isolate:false). Deferring the import to +// inside each test keeps that import out of collection time. +async function syncFailedRecords() { + const { SyncService } = await import('@/app/api/quickbooks/sync/sync.service') + await new SyncService(user).syncFailedRecords() +} + +describe('payout resync', () => { + const apis = setupPaymentSucceededTest() + + async function seedResolvableBatchedInvoices() { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + } + + it('creates the deposit on a retry once the transient cause clears', async () => { + await seedResolvableBatchedInvoices() + await seedFailedPayout({ + payoutId: 'po_test_1', + lineItems, + netAmount: 34425, + feeCents: 575, + arrivalDate: 1713744000, + }) + + await syncFailedRecords() + + expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) + const log = await db.query.QBSyncLog.findFirst({ + where: eq(QBSyncLog.copilotId, 'po_test_1'), + }) + expect(log?.status).toBe(LogStatus.SUCCESS) + expect(log?.quickbooksId).toBe('qb-deposit-1') + const payoutRow = await db.query.QBPayoutSync.findFirst() + expect(payoutRow?.qbDepositId).toBe('qb-deposit-1') + }) + + it('recovers a payout that arrived before its invoice.paid committed', async () => { + // Failed log seeded first, invoices resolvable only now (ordering fixed). + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedFailedPayout({ + payoutId: 'po_test_1', + lineItems, + netAmount: 34425, + feeCents: 575, + arrivalDate: 1713744000, + errorMessage: 'no SUCCESS INVOICE/PAID', + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + await syncFailedRecords() + + const log = await db.query.QBSyncLog.findFirst({ + where: eq(QBSyncLog.copilotId, 'po_test_1'), + }) + expect(log?.status).toBe(LogStatus.SUCCESS) + }) + + it('does not create a second deposit when qbDepositId is already set', async () => { + await seedResolvableBatchedInvoices() + await seedFailedPayout({ + payoutId: 'po_test_1', + lineItems, + netAmount: 34425, + feeCents: 575, + arrivalDate: 1713744000, + qbDepositId: 'qb-deposit-existing', + }) + + await syncFailedRecords() + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + const log = await db.query.QBSyncLog.findFirst({ + where: eq(QBSyncLog.copilotId, 'po_test_1'), + }) + expect(log?.status).toBe(LogStatus.SUCCESS) + expect(log?.quickbooksId).toBe('qb-deposit-existing') + }) + + it('keeps a missing-context payout terminal', async () => { + await seedResolvableBatchedInvoices() + // FAILED log with NO qb_payout_sync row → cannot rebuild. + await db.insert(QBSyncLog).values({ + portalId: TEST_PORTAL_ID, + entityType: 'payout' as never, + eventType: 'settled' as never, + status: LogStatus.FAILED, + copilotId: 'po_orphan', + shouldRetry: true, + }) + + await syncFailedRecords() + + const log = await db.query.QBSyncLog.findFirst({ + where: eq(QBSyncLog.copilotId, 'po_orphan'), + }) + expect(log?.status).toBe(LogStatus.FAILED) + expect(log?.shouldRetry).toBe(false) + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + }) +}) From 8bb182dd62be12efe603a460ee93dd3316519f54 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 3 Aug 2026 14:23:55 +0545 Subject: [PATCH 2/2] refactor(OUT-4005): insert payout sync and log in parallel in seedFailedPayout The qb_payout_sync and qb_sync_logs inserts are independent (no FK), so run them together with Promise.all instead of sequentially. Co-Authored-By: Claude Opus 4.8 --- test/helpers/seed.ts | 45 +++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index 7503e7d7..dbfbf089 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -236,25 +236,28 @@ export async function seedFailedPayout(opts: { qbDepositId?: string errorMessage?: string }) { - await db.insert(QBPayoutSync).values({ - portalId: TEST_PORTAL_ID, - payoutId: opts.payoutId, - lineItems: opts.lineItems, - netAmount: opts.netAmount, - feeAmount: opts.feeCents, - arrivalDate: opts.arrivalDate, - qbDepositId: opts.qbDepositId ?? null, - }) - await db.insert(QBSyncLog).values({ - portalId: TEST_PORTAL_ID, - entityType: EntityType.PAYOUT, - eventType: EventType.SETTLED, - status: LogStatus.FAILED, - copilotId: opts.payoutId, - // Cents-as-string, matching what the webhook writes for a payout log. - amount: opts.netAmount.toFixed(2), - feeAmount: opts.feeCents.toFixed(2), - errorMessage: opts.errorMessage ?? 'QuickBooks timed out', - shouldRetry: true, - }) + // Independent tables, no FK between them — insert both at once. + await Promise.all([ + db.insert(QBPayoutSync).values({ + portalId: TEST_PORTAL_ID, + payoutId: opts.payoutId, + lineItems: opts.lineItems, + netAmount: opts.netAmount, + feeAmount: opts.feeCents, + arrivalDate: opts.arrivalDate, + qbDepositId: opts.qbDepositId ?? null, + }), + db.insert(QBSyncLog).values({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + copilotId: opts.payoutId, + // Cents-as-string, matching what the webhook writes for a payout log. + amount: opts.netAmount.toFixed(2), + feeAmount: opts.feeCents.toFixed(2), + errorMessage: opts.errorMessage ?? 'QuickBooks timed out', + shouldRetry: true, + }), + ]) }