diff --git a/src/app/api/quickbooks/payout/payout.errors.ts b/src/app/api/quickbooks/payout/payout.errors.ts new file mode 100644 index 00000000..eb35c63f --- /dev/null +++ b/src/app/api/quickbooks/payout/payout.errors.ts @@ -0,0 +1,19 @@ +import { getShouldRetryForCategory } from '@/utils/synclog' +import { getMessageAndCodeFromError } from '@/utils/error' + +// Payout problems that retrying will never fix, so we stop trying +// (refund lines, negative fee, duplicate line items, wrong total). +export class TerminalPayoutError extends Error {} + +// A payout that mixes batched and non-batched invoices. Extends +// TerminalPayoutError so it also stops retrying, but stays its own type so +// we can send the special "mixed payout" alert. +export class MixedPayoutIntentError extends TerminalPayoutError {} + +// Terminal payout problems never retry. Everything else (invoice not saved +// yet, missing bank ref, rate-limit, QB 5xx, suspended account) uses the +// shared rule, which still stops on dead tokens (AUTH). +export function getShouldRetryForPayout(error: unknown): boolean { + if (error instanceof TerminalPayoutError) return false + return getShouldRetryForCategory(getMessageAndCodeFromError(error)) +} diff --git a/src/app/api/quickbooks/payout/payout.service.ts b/src/app/api/quickbooks/payout/payout.service.ts new file mode 100644 index 00000000..9054ced2 --- /dev/null +++ b/src/app/api/quickbooks/payout/payout.service.ts @@ -0,0 +1,231 @@ +import httpStatus from 'http-status' +import { and, eq, isNull } from 'drizzle-orm' + +import { BaseService } from '@/app/api/core/services/base.service' +import APIError from '@/app/api/core/exceptions/api' +import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' +import { PaymentService } from '@/app/api/quickbooks/payment/payment.service' +import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { + MixedPayoutIntentError, + TerminalPayoutError, +} from '@/app/api/quickbooks/payout/payout.errors' +import { + QBPayoutSync, + QBPayoutSyncSelectSchemaType, +} from '@/db/schema/qbPayoutSync' +import { PayoutLineItem } from '@/type/dto/webhook.dto' +import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' +import { AccountTypeObj } from '@/constant/qbConnection' +import { validateAccessToken } from '@/utils/auth' +import User from '@/app/api/core/models/User.model' + +export class PayoutService extends BaseService { + private syncLogService: SyncLogService + + constructor(user: User) { + super(user) + this.syncLogService = new SyncLogService(user) + } + + // Same (portalId, payoutId) updates the same row, so a re-sent payout + // never makes a duplicate. + async upsertPayoutSync(input: { + payoutId: string + lineItems: PayoutLineItem[] + netAmount: number + feeCents: number + arrivalDate: number + }): Promise { + const [row] = await this.db + .insert(QBPayoutSync) + .values({ + portalId: this.user.workspaceId, + payoutId: input.payoutId, + lineItems: input.lineItems, + netAmount: input.netAmount, + feeAmount: input.feeCents, + arrivalDate: input.arrivalDate, + }) + .onConflictDoUpdate({ + target: [QBPayoutSync.portalId, QBPayoutSync.payoutId], + // Must match the partial unique index (only rows where deleted_at is + // null). In drizzle-orm 0.42 that goes in `targetWhere`, not `where`. + targetWhere: isNull(QBPayoutSync.deletedAt), + set: { + lineItems: input.lineItems, + netAmount: input.netAmount, + feeAmount: input.feeCents, + arrivalDate: input.arrivalDate, + }, + }) + .returning() + return row + } + + async getPayoutSync( + payoutId: string, + ): Promise { + const row = await this.db.query.QBPayoutSync.findFirst({ + where: and( + eq(QBPayoutSync.portalId, this.user.workspaceId), + eq(QBPayoutSync.payoutId, payoutId), + isNull(QBPayoutSync.deletedAt), + ), + }) + return row ?? null + } + + // Checks the payout, finds its payments, then builds and creates the deposit. + // Neither caller claims again. Returns { depositId: null } when there is + // nothing to deposit. + async reconcile( + row: QBPayoutSyncSelectSchemaType, + qbTokenInfo: IntuitAPITokensType, + opts: { runIdempotencyCheck: boolean }, + ): Promise<{ depositId: string | null }> { + validateAccessToken(qbTokenInfo) + + const payoutId = row.payoutId + // One source for the note, used to both find and create the deposit, + // so the two can never drift apart. + const privateNote = `Stripe payout ${payoutId}` + const lineItems = row.lineItems + const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId) + const grossCents = lineItems.reduce( + (sum, line) => sum + line.grossAmount, + 0, + ) + const feeCents = lineItems.reduce((sum, line) => sum + line.feeAmount, 0) + const netAmount = row.netAmount + + // These problems never fix themselves on retry, so fail for good. + if (lineItems.some((line) => line.grossAmount < 0)) { + throw new TerminalPayoutError( + `Payout ${payoutId} contains refund lines; batched deposit unsupported in v1`, + ) + } + if (feeCents < 0) { + throw new TerminalPayoutError( + `Payout ${payoutId} has a negative aggregate fee (${feeCents}); unsupported in v1`, + ) + } + if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) { + throw new TerminalPayoutError( + `Payout ${payoutId} contains duplicate invoice line items`, + ) + } + + // On resync only: reuse a deposit we already made, or find one already in QBO. + if (opts.runIdempotencyCheck) { + if (row.qbDepositId) return { depositId: row.qbDepositId } + const intuitApi = new IntuitAPI(qbTokenInfo) + const txnDate = new Date(row.arrivalDate * 1000) + .toISOString() + .split('T')[0] + const existing = await intuitApi.getDepositsByTxnDate(txnDate) + const match = existing.find( + (deposit) => deposit.PrivateNote === privateNote, + ) + if (match) { + await this.db + .update(QBPayoutSync) + .set({ qbDepositId: match.Id }) + .where( + and( + eq(QBPayoutSync.id, row.id), + eq(QBPayoutSync.portalId, this.user.workspaceId), + ), + ) + return { depositId: match.Id } + } + } + + const paymentIdByInvoice = + await this.syncLogService.getSuccessfulPaidPaymentIds(copilotInvoiceIds) + const unresolved = copilotInvoiceIds.filter( + (id) => !paymentIdByInvoice.has(id), + ) + if (unresolved.length > 0) { + // Can retry: the invoice.paid event may just not be saved yet. + throw new APIError( + httpStatus.NOT_FOUND, + `Payout ${payoutId}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`, + ) + } + + const allBatched = copilotInvoiceIds.every( + (id) => paymentIdByInvoice.get(id)?.isBatchedDeposit, + ) + const allNonBatched = copilotInvoiceIds.every( + (id) => !paymentIdByInvoice.get(id)?.isBatchedDeposit, + ) + // All non-batched means the fees were already booked, so nothing to deposit. + if (allNonBatched) return { depositId: null } + if (!allBatched) { + throw new MixedPayoutIntentError( + `Payout ${payoutId} mixes batched and non-batched invoices; unsupported`, + ) + } + + if (grossCents - feeCents !== netAmount) { + throw new TerminalPayoutError( + `Payout ${payoutId}: deposit total ${grossCents - feeCents} != payout net ${netAmount}`, + ) + } + + const bankAccountRef = qbTokenInfo.bankAccountRef + if (!bankAccountRef) { + // Can retry: works once a bank account is set in settings. + 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) + 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(row.arrivalDate * 1000).toISOString().split('T')[0], + privateNote, + }, + ) + + await this.db + .update(QBPayoutSync) + .set({ qbDepositId: depositId }) + .where( + and( + eq(QBPayoutSync.id, row.id), + eq(QBPayoutSync.portalId, this.user.workspaceId), + ), + ) + + return { depositId } + } +} diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 3620b989..baefecb1 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -287,6 +287,21 @@ export const QBDepositResponseSchema = z.object({ }) export type QBDepositResponseType = z.infer +export const QBDepositQueryResponseSchema = z.object({ + Deposit: z + .array( + z.object({ + Id: z.string(), + PrivateNote: z.string().optional(), + TxnDate: z.string().optional(), + }), + ) + .optional(), +}) +export type QBDepositQueryResponseType = z.infer< + typeof QBDepositQueryResponseSchema +> + export const QBDeletePayloadSchema = z.object({ SyncToken: z.string(), Id: z.string(), diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 6e6cb698..10470bf7 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -16,6 +16,7 @@ import { QBDepositCreatePayloadType, QBDepositResponseSchema, QBDepositResponseType, + QBDepositQueryResponseSchema, QBDeletePayloadType, QBDestructiveInvoicePayloadSchema, QBItemRowType, @@ -1006,6 +1007,42 @@ export default class IntuitAPI { return parsed } + // Read all pages so we don't miss a deposit on a busy day. Miss one and + // resync makes a duplicate deposit that QBO won't let us delete. maxPages is + // just a safety cap — hitting it would need 50k deposits in a single day. + async _getDepositsByTxnDate( + txnDate: string, + ): Promise> { + CustomLogger.info({ + obj: { txnDate }, + message: `IntuitAPI#getDepositsByTxnDate | start for realmId: ${this.tokens.intuitRealmId}.`, + }) + + const pageSize = 1000 + const maxPages = 50 + const deposits: Array<{ Id: string; PrivateNote?: string }> = [] + let startPosition = 1 + + for (let pages = 0; pages < maxPages; pages++) { + const query = `select Id, PrivateNote, TxnDate from Deposit where TxnDate = '${escapeForQBQuery(txnDate)}' STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}` + const response = await this.customQuery(query) + if (!response) return deposits + + const envelope = QBDepositQueryResponseSchema.parse(response) + const page = envelope.Deposit ?? [] + deposits.push(...page) + + if (page.length < pageSize) return deposits + startPosition += pageSize + } + + CustomLogger.error({ + obj: { txnDate, maxPages }, + message: `IntuitAPI#getDepositsByTxnDate | pagination cap (${maxPages} pages) hit for realmId: ${this.tokens.intuitRealmId} — result truncated at ${deposits.length} deposits.`, + }) + return deposits + } + async _deletePurchase( payload: QBDeletePayloadType, ): Promise { @@ -1134,5 +1171,6 @@ export default class IntuitAPI { deletePayment = this.wrapWithRetry(this._deletePayment) deletePurchase = this.wrapWithRetry(this._deletePurchase) createDeposit = this.wrapWithRetry(this._createDeposit) + getDepositsByTxnDate = this._getDepositsByTxnDate.bind(this) getCompanyInfo = this._getCompanyInfo.bind(this) } diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 94ee7903..676256a3 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -142,6 +142,8 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { createDeposit: vi.fn().mockResolvedValue({ Deposit: { Id: 'qb-deposit-1', SyncToken: '0' }, }), + // Payout resync checks for an existing deposit first — none by default. + getDepositsByTxnDate: vi.fn().mockResolvedValue([]), // Handler ignores the response; it just needs the call to succeed (OUT-3921). voidInvoice: vi.fn().mockResolvedValue({ Invoice: { Id: TEST_QB_INVOICE_ID, SyncToken: '1' }, diff --git a/test/integration/quickbooks/payoutResync/payoutServiceReconcile.test.ts b/test/integration/quickbooks/payoutResync/payoutServiceReconcile.test.ts new file mode 100644 index 00000000..aa3042a8 --- /dev/null +++ b/test/integration/quickbooks/payoutResync/payoutServiceReconcile.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect } from 'vitest' +import { and, eq } from 'drizzle-orm' + +import { PayoutService } from '@/app/api/quickbooks/payout/payout.service' +import { + TerminalPayoutError, + MixedPayoutIntentError, +} from '@/app/api/quickbooks/payout/payout.errors' +import { QBPayoutSync } from '@/db/schema/qbPayoutSync' +import { db } from '@/db' +import User from '@/app/api/core/models/User.model' +import { getValidQbTokens } from '@/utils/tokenRefresh' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + 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 + +// No `@test/helpers/tokens` helper exists. AuthService.getQBPortalConnection +// would be the obvious pick, but it transitively imports next/server's +// `after` (auth.service.ts), which corrupts NTARH's AsyncLocalStorage for +// every other postWebhook-based test sharing this worker (isolate: false) — +// the same class of bug test/integration/setup.ts already documents and +// shims for afterIfAvailable. getValidQbTokens is the exact function +// getQBPortalConnection delegates to for a healthy/synced seeded portal +// (our fixtures always have isEnabled/syncFlag true), with no next/server +// in its import graph — a plain DB read, IntuitAPI already mocked. +async function getQBTokens() { + return getValidQbTokens(TEST_PORTAL_ID) +} + +async function insertPayoutRow(overrides = {}) { + const [row] = await db + .insert(QBPayoutSync) + .values({ + portalId: TEST_PORTAL_ID, + payoutId: 'po_test_1', + lineItems: [ + { + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 20000, + feeAmount: 375, + }, + { + copilotInvoiceId: 'inv-cop-0002', + grossAmount: 15000, + feeAmount: 200, + }, + ], + netAmount: 34425, + feeAmount: 575, + arrivalDate: 1713744000, + ...overrides, + }) + .returning() + return row +} + +describe('PayoutService.reconcile', () => { + const apis = setupPaymentSucceededTest() + + async function seedResolvableBatchedPayout() { + 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 a deposit for an all-batched payout and persists qbDepositId', async () => { + await seedResolvableBatchedPayout() + const row = await insertPayoutRow() + + const { depositId } = await new PayoutService(user).reconcile( + row, + await getQBTokens(), + { runIdempotencyCheck: false }, + ) + + expect(depositId).toBe('qb-deposit-1') + expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) + const saved = await db.query.QBPayoutSync.findFirst() + expect(saved?.qbDepositId).toBe('qb-deposit-1') + }) + + it('throws NOT_FOUND (retryable) when an invoice payment is unresolved', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + // Only one of two invoices seeded → the other is unresolved. + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + const row = await insertPayoutRow() + + await expect( + new PayoutService(user).reconcile(row, await getQBTokens(), { + runIdempotencyCheck: false, + }), + ).rejects.toThrow(/no SUCCESS INVOICE\/PAID/) + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + }) + + it('throws TerminalPayoutError on a sum mismatch', async () => { + await seedResolvableBatchedPayout() + const row = await insertPayoutRow({ netAmount: 99999 }) + + await expect( + new PayoutService(user).reconcile(row, await getQBTokens(), { + runIdempotencyCheck: false, + }), + ).rejects.toBeInstanceOf(TerminalPayoutError) + }) + + it('throws MixedPayoutIntentError when intents are mixed', async () => { + 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: false, + }) + const row = await insertPayoutRow() + + await expect( + new PayoutService(user).reconcile(row, await getQBTokens(), { + runIdempotencyCheck: false, + }), + ).rejects.toBeInstanceOf(MixedPayoutIntentError) + }) + + it('returns depositId null when all invoices are non-batched', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: false }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: false, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: false, + }) + const row = await insertPayoutRow() + + const { depositId } = await new PayoutService(user).reconcile( + row, + await getQBTokens(), + { runIdempotencyCheck: false }, + ) + expect(depositId).toBeNull() + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + }) + + it('idempotency: skips creation when qbDepositId is already set', async () => { + await seedResolvableBatchedPayout() + const row = await insertPayoutRow({ qbDepositId: 'qb-deposit-existing' }) + + const { depositId } = await new PayoutService(user).reconcile( + row, + await getQBTokens(), + { runIdempotencyCheck: true }, + ) + expect(depositId).toBe('qb-deposit-existing') + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + }) + + it('idempotency: reconciles to an existing QBO deposit found by note', async () => { + await seedResolvableBatchedPayout() + const row = await insertPayoutRow() + apis.intuit.getDepositsByTxnDate.mockResolvedValueOnce([ + { Id: 'qb-deposit-found', PrivateNote: 'Stripe payout po_test_1' }, + ]) + + const { depositId } = await new PayoutService(user).reconcile( + row, + await getQBTokens(), + { runIdempotencyCheck: true }, + ) + expect(depositId).toBe('qb-deposit-found') + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + const saved = await db.query.QBPayoutSync.findFirst() + expect(saved?.qbDepositId).toBe('qb-deposit-found') + }) +}) + +describe('PayoutService.upsertPayoutSync', () => { + setupPaymentSucceededTest() + + it('re-delivery with the same payoutId updates the row instead of inserting a duplicate', async () => { + const service = new PayoutService(user) + + // Exercises the ON CONFLICT arbiter directly: the unique index is + // partial (`WHERE deleted_at IS NULL`), so a wrong predicate here throws + // "no unique or exclusion constraint matching the ON CONFLICT specification" + // on this very call rather than silently inserting a duplicate row. + await service.upsertPayoutSync({ + payoutId: 'po_test_1', + lineItems: [ + { + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 20000, + feeAmount: 375, + }, + ], + netAmount: 19625, + feeCents: 375, + arrivalDate: 1713744000, + }) + await service.upsertPayoutSync({ + payoutId: 'po_test_1', + lineItems: [ + { + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 25000, + feeAmount: 500, + }, + ], + netAmount: 24500, + feeCents: 500, + arrivalDate: 1713744000, + }) + + const rows = await db + .select() + .from(QBPayoutSync) + .where( + and( + eq(QBPayoutSync.portalId, TEST_PORTAL_ID), + eq(QBPayoutSync.payoutId, 'po_test_1'), + ), + ) + + expect(rows).toHaveLength(1) + expect(rows[0].netAmount).toBe(24500) + expect(rows[0].feeAmount).toBe(500) + expect(rows[0].lineItems).toEqual([ + { copilotInvoiceId: 'inv-cop-0001', grossAmount: 25000, feeAmount: 500 }, + ]) + }) +}) diff --git a/test/unit/dto/depositQueryResponse.test.ts b/test/unit/dto/depositQueryResponse.test.ts new file mode 100644 index 00000000..551d1087 --- /dev/null +++ b/test/unit/dto/depositQueryResponse.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { QBDepositQueryResponseSchema } from '@/type/dto/intuitAPI.dto' + +describe('QBDepositQueryResponseSchema', () => { + it('parses a QueryResponse with deposits', () => { + const parsed = QBDepositQueryResponseSchema.parse({ + Deposit: [{ Id: 'dep-1', PrivateNote: 'Stripe payout po_1' }], + }) + expect(parsed.Deposit?.[0].Id).toBe('dep-1') + }) + + it('parses an empty QueryResponse (no Deposit key)', () => { + const parsed = QBDepositQueryResponseSchema.parse({}) + expect(parsed.Deposit).toBeUndefined() + }) +}) diff --git a/test/unit/dto/payoutLineItem.test.ts b/test/unit/dto/payoutLineItem.test.ts new file mode 100644 index 00000000..60e8a849 --- /dev/null +++ b/test/unit/dto/payoutLineItem.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { PayoutLineItemSchema } from '@/type/dto/webhook.dto' + +describe('PayoutLineItemSchema', () => { + it('parses a valid line item', () => { + const parsed = PayoutLineItemSchema.parse({ + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 20000, + feeAmount: 375, + }) + expect(parsed.copilotInvoiceId).toBe('inv-cop-0001') + }) + + it('rejects a line item missing the invoice id', () => { + expect(() => + PayoutLineItemSchema.parse({ grossAmount: 1, feeAmount: 0 }), + ).toThrow() + }) +}) diff --git a/test/unit/payout/payoutErrors.test.ts b/test/unit/payout/payoutErrors.test.ts new file mode 100644 index 00000000..2d59dd17 --- /dev/null +++ b/test/unit/payout/payoutErrors.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import httpStatus from 'http-status' + +import APIError from '@/app/api/core/exceptions/api' +import { refreshTokenExpireMessage } from '@/utils/auth' +import { + TerminalPayoutError, + MixedPayoutIntentError, + getShouldRetryForPayout, +} from '@/app/api/quickbooks/payout/payout.errors' + +describe('getShouldRetryForPayout', () => { + it('is terminal for deterministic payout errors', () => { + expect( + getShouldRetryForPayout(new TerminalPayoutError('refund lines')), + ).toBe(false) + expect(getShouldRetryForPayout(new MixedPayoutIntentError('mixed'))).toBe( + false, + ) + }) + + it('is retryable for an unresolved-invoice NOT_FOUND (webhook ordering)', () => { + expect( + getShouldRetryForPayout( + new APIError(httpStatus.NOT_FOUND, 'no PAID log'), + ), + ).toBe(true) + }) + + it('is terminal for a dead refresh token (AUTH)', () => { + expect(getShouldRetryForPayout(new Error(refreshTokenExpireMessage))).toBe( + false, + ) + }) + + it('treats a mixed-intent error as a terminal payout error', () => { + expect(new MixedPayoutIntentError('x') instanceof TerminalPayoutError).toBe( + true, + ) + }) +}) diff --git a/test/unit/utils/intuitAPI.getDepositsByTxnDate.test.ts b/test/unit/utils/intuitAPI.getDepositsByTxnDate.test.ts new file mode 100644 index 00000000..a58333d7 --- /dev/null +++ b/test/unit/utils/intuitAPI.getDepositsByTxnDate.test.ts @@ -0,0 +1,100 @@ +/** + * Unit tests for `IntuitAPI._getDepositsByTxnDate` — the idempotency lookup + * behind the payout resync path. Coverage focus: pagination correctness. + * A portal can have >1 page of deposits on the same TxnDate; missing a + * match past position 1000 would let a resync create a duplicate deposit + * (QBO has no deleteDeposit, so this is a real double-book vector). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@sentry/nextjs', () => ({ + withScope: vi.fn(), + captureMessage: vi.fn(), + captureException: vi.fn(), +})) + +vi.mock('@/utils/logger', () => ({ + default: { info: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@/helper/fetch.helper', () => ({ + getFetcher: vi.fn(), + postFetcher: vi.fn(), +})) + +import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' + +const baseTokens: IntuitAPITokensType = { + accessToken: 'access', + refreshToken: 'refresh', + intuitRealmId: 'realm-1', + incomeAccountRef: 'income', + expenseAccountRef: 'expense', + assetAccountRef: 'asset', + serviceItemRef: 'service', + clientFeeRef: 'client-fee', + bankAccountRef: 'bank', +} + +// Builds a deposit row in the shape QBO returns inside `QueryResponse.Deposit`. +const row = (id: string, privateNote?: string) => ({ + Id: id, + ...(privateNote ? { PrivateNote: privateNote } : {}), + TxnDate: '2026-07-29', +}) + +// `customQuery` is a public field on IntuitAPI (`this.wrapWithRetry(this._customQuery)`). +// Replace it on the instance after construction, matching the pattern in +// intuitAPI.test.ts / intuitAPI.accounts.test.ts. +function makeApi(pages: Array) { + const api = new IntuitAPI(baseTokens) + const customQuery = vi.fn() + for (const page of pages) { + customQuery.mockResolvedValueOnce(page) + } + customQuery.mockImplementation(() => { + throw new Error('customQuery called more times than test configured') + }) + ;(api as unknown as { customQuery: unknown }).customQuery = customQuery + return { api, customQuery } +} + +describe('IntuitAPI#getDepositsByTxnDate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns all rows from a single short page without a second call', async () => { + const { api, customQuery } = makeApi([ + { Deposit: [row('dep-1', 'Stripe payout po_1'), row('dep-2')] }, + ]) + + const result = await api.getDepositsByTxnDate('2026-07-29') + + expect(result).toHaveLength(2) + expect(customQuery).toHaveBeenCalledTimes(1) + }) + + it('paginates across a full page and a short page, advancing STARTPOSITION 1 -> 1001', async () => { + const page1 = { + Deposit: Array.from({ length: 1000 }, (_, i) => row(`p1-${i}`)), + } + const page2 = { + Deposit: [row('p2-0', 'Stripe payout po_target'), row('p2-1')], + } + const { api, customQuery } = makeApi([page1, page2]) + + const result = await api.getDepositsByTxnDate('2026-07-29') + + expect(customQuery).toHaveBeenCalledTimes(2) + const firstQuery = customQuery.mock.calls[0][0] as string + const secondQuery = customQuery.mock.calls[1][0] as string + expect(firstQuery).toContain('STARTPOSITION 1 ') + expect(secondQuery).toContain('STARTPOSITION 1001 ') + + expect(result).toHaveLength(1002) + expect( + result.some((d) => d.PrivateNote === 'Stripe payout po_target'), + ).toBe(true) + }) +})