From ddd492125694bbdfa258140fd4e4299d5fb6c771 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 22 May 2026 14:47:23 +0545 Subject: [PATCH 1/6] test(OUT-3773): mock sleep and afterIfAvailable globally for integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook pre-claim sleeps (INVOICE_UPDATED, INVOICE_VOIDED, PAYMENT_SUCCEEDED) would add ≥7s per test; we don't exercise race ordering at this layer. afterIfAvailable is mocked because direct imports of SyncLogService transitively load next/server's AsyncLocalStorage shim and corrupt next-test-api-route-handler's request lifecycle. The shim calls the callback directly, keeping next/server out of the module graph entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/integration/setup.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/integration/setup.ts b/test/integration/setup.ts index caff9e7e..d9b8c6e8 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -78,3 +78,19 @@ vi.mock('@sentry/nextjs', () => ({ addBreadcrumb: vi.fn(), init: vi.fn(), })) + +// Webhook pre-claim sleeps would add ≥7s per test; we don't exercise race +// ordering at this layer. +vi.mock('@/utils/sleep', () => ({ + sleep: vi.fn().mockResolvedValue(undefined), +})) + +// Importing modules that pull `next/server` corrupts NTARH's AsyncLocalStorage. +// Shimming this entry point keeps the next/server import out of the graph. +vi.mock('@/app/api/core/utils/afterIfAvailable', () => ({ + afterIfAvailable: (callback: () => Promise) => { + void callback().catch((err) => { + console.error('[afterIfAvailable mock] callback failed:', err) + }) + }, +})) From 45eae493bb0f8ce07d50fb726e9755403b638b25 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 22 May 2026 14:47:32 +0545 Subject: [PATCH 2/6] test(OUT-3773): extend test helpers and fixture for payment.succeeded - Add TEST_COPILOT_PAYMENT_ID and TEST_QB_PURCHASE_ID seed constants. - Add createPurchase and deletePurchase defaults to createMockIntuitAPI. - Make getAnAccount echo the id back so asset/expense lookups return the ref that was passed in; name-only callers still get the income ref. - Flip getInvoice default from undefined to a real invoice whose number matches TEST_INVOICE_NUMBER. No existing test calls getInvoice. - New setupPaymentSucceededTest helper and paymentSucceeded fixture. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/fixtures/paymentSucceeded.webhook.ts | 32 +++++++++++++++++ test/helpers/mocks.ts | 32 ++++++++++++----- test/helpers/paymentSucceededTestSetup.ts | 44 +++++++++++++++++++++++ test/helpers/seed.ts | 2 ++ 4 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/paymentSucceeded.webhook.ts create mode 100644 test/helpers/paymentSucceededTestSetup.ts diff --git a/test/fixtures/paymentSucceeded.webhook.ts b/test/fixtures/paymentSucceeded.webhook.ts new file mode 100644 index 00000000..6cf9ec7e --- /dev/null +++ b/test/fixtures/paymentSucceeded.webhook.ts @@ -0,0 +1,32 @@ +import type { z } from 'zod' + +import { PaymentStatus } from '@/app/api/core/types/invoice' +import { PaymentSucceededResponseSchema } from '@/type/dto/webhook.dto' +import { + TEST_COPILOT_INVOICE_ID, + TEST_COPILOT_PAYMENT_ID, +} from '@test/helpers/seed' + +type Envelope = { + eventType: 'payment.succeeded' + object: 'payment' +} + +type PaymentSucceededFixture = Envelope & + z.input + +const paymentSucceededPayload: PaymentSucceededFixture = { + eventType: 'payment.succeeded', + object: 'payment', + data: { + id: TEST_COPILOT_PAYMENT_ID, + invoiceId: TEST_COPILOT_INVOICE_ID, + status: PaymentStatus.SUCCEEDED, + paymentMethod: 'creditCard', + brand: 'visa', + feeAmount: { paidByPlatform: 2500, paidByClient: 0 }, + createdAt: '2024-02-21T15:31:16.789Z', + }, +} + +export default paymentSucceededPayload diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 1ed048ba..0fccf577 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -5,6 +5,8 @@ import { TEST_INCOME_ACCOUNT_REF, TEST_INTERNAL_USER_ID, TEST_PORTAL_ID, + TEST_INVOICE_NUMBER, + TEST_QB_PURCHASE_ID, } from './seed' // Restricts override keys to the actual method names of the underlying class @@ -60,7 +62,11 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) { type: 'recurring', }), getPayments: vi.fn().mockResolvedValue({ data: [] }), - getInvoice: vi.fn().mockResolvedValue(undefined), + // payment.succeeded needs a real invoice object to proceed past the getInvoice guard (OUT-3773) + getInvoice: vi.fn().mockResolvedValue({ + id: 'inv-cop-0001', + number: TEST_INVOICE_NUMBER, + }), ...overrides, } } @@ -76,13 +82,17 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) { export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { return { getAnItem: vi.fn().mockResolvedValue(undefined), - getAnAccount: vi.fn().mockResolvedValue({ - Id: TEST_INCOME_ACCOUNT_REF, - Name: 'Sales of Product Income', - SyncToken: '0', - Active: true, - AccountType: 'Income', - }), + // Echo the id back so callers (checkAndUpdateAccountStatus) get the ref + // they asked for; name-only queries fall back to the income account. + getAnAccount: vi + .fn() + .mockImplementation(async (_name?: string, id?: string) => ({ + Id: id ?? TEST_INCOME_ACCOUNT_REF, + Name: 'Sales of Product Income', + SyncToken: '0', + Active: true, + AccountType: 'Income', + })), createItem: vi.fn().mockResolvedValue({ Id: '999', Name: 'Test Product', @@ -115,6 +125,12 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { createPayment: vi.fn().mockResolvedValue({ Payment: { Id: 'qb-pay-1', SyncToken: '0' }, }), + createPurchase: vi.fn().mockResolvedValue({ + Purchase: { Id: TEST_QB_PURCHASE_ID, SyncToken: '0' }, + }), + deletePurchase: vi.fn().mockResolvedValue({ + Purchase: { Id: TEST_QB_PURCHASE_ID, status: 'Deleted' }, + }), // webhookInvoiceCreated pre-flights QBO for DocNumber collisions before // every createInvoice call (OUT-3710). Default to "no collisions" so the // base Assembly invoice number is used; override per-test to exercise the diff --git a/test/helpers/paymentSucceededTestSetup.ts b/test/helpers/paymentSucceededTestSetup.ts new file mode 100644 index 00000000..2a7b5c0a --- /dev/null +++ b/test/helpers/paymentSucceededTestSetup.ts @@ -0,0 +1,44 @@ +import { beforeEach, afterEach, vi } from 'vitest' +import { truncateAllTestTables } from '@test/helpers/testDb' +import { + installMockApis, + type MockCopilotAPI, + type MockIntuitAPI, +} from '@test/helpers/mocks' + +type InstallOpts = Parameters[0] + +export interface PaymentSucceededTestHandle { + copilot: MockCopilotAPI + intuit: MockIntuitAPI +} + +/** + * Registers the standard `beforeEach` (truncate + installMockApis) and + * `afterEach` (clearAllMocks) hooks used by every payment.succeeded + * integration test. Mirrors `setupInvoiceCreatedTest` / + * `setupPriceCreatedTest`. The `optsFactory` is invoked once per test so + * callers can supply overrides whose underlying `vi.fn()`s are freshly + * instantiated. + */ +export function setupPaymentSucceededTest( + optsFactory?: () => InstallOpts, +): PaymentSucceededTestHandle { + const handle = {} as PaymentSucceededTestHandle + + beforeEach(async () => { + await truncateAllTestTables() + const { copilot, intuit } = installMockApis(optsFactory?.()) + handle.copilot = copilot + handle.intuit = intuit + }) + + afterEach(() => { + // clearAllMocks (not restoreAllMocks) — the module-level mock factories in + // test/integration/setup.ts must stay installed across tests; we only want + // to reset call counts and implementations set in beforeEach. + vi.clearAllMocks() + }) + + return handle +} diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index ac338206..2950f05e 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -111,6 +111,8 @@ export const TEST_QB_CUSTOMER_ID = 'qb-cust-1' export const TEST_QB_INVOICE_ID = 'qb-inv-1' export const TEST_INVOICE_NUMBER = 'INV-0001' export const TEST_COPILOT_INVOICE_ID = 'inv-cop-0001' +export const TEST_COPILOT_PAYMENT_ID = 'pay-cop-0001' +export const TEST_QB_PURCHASE_ID = 'qb-purch-1' type CustomerOverrides = Partial> type InvoiceSyncOverrides = Partial> From 6a2faf35127ae236d7aa3c11132208ef5460fbf2 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 22 May 2026 14:47:42 +0545 Subject: [PATCH 3/6] test(OUT-3773): integration tests for payment.succeeded webhook event Eight tests pinning every branch of WebhookService.handlePaymentSucceeded: happy path, absorbed-fee flag off, no platform-paid fee, idempotent re-delivery, Copilot invoice not found, local invoice-sync miss, QB createPurchase failure, and the revert-on-log-failure rollback. copilotInvoiceNotFound pins a production quirk: the !invoice throw at webhook.service.ts:518 sits outside the try/catch on line 524, so the handler returns 404 with a leaked PENDING claim row instead of logging FAILED. A follow-up ticket should move the check inside the try. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../absorbedFeeFlagOff.test.ts | 26 ++++++ .../copilotInvoiceNotFound.test.ts | 51 ++++++++++++ .../paymentSucceeded/happyPath.test.ts | 82 +++++++++++++++++++ .../paymentSucceeded/idempotency.test.ts | 54 ++++++++++++ .../invoiceSyncNotFound.test.ts | 40 +++++++++ .../paymentSucceeded/noPlatformFee.test.ts | 42 ++++++++++ .../qbCreatePurchaseFails.test.ts | 50 +++++++++++ .../revertOnLogFailure.test.ts | 64 +++++++++++++++ 8 files changed, 409 insertions(+) create mode 100644 test/integration/quickbooks/paymentSucceeded/absorbedFeeFlagOff.test.ts create mode 100644 test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts create mode 100644 test/integration/quickbooks/paymentSucceeded/happyPath.test.ts create mode 100644 test/integration/quickbooks/paymentSucceeded/idempotency.test.ts create mode 100644 test/integration/quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts create mode 100644 test/integration/quickbooks/paymentSucceeded/noPlatformFee.test.ts create mode 100644 test/integration/quickbooks/paymentSucceeded/qbCreatePurchaseFails.test.ts create mode 100644 test/integration/quickbooks/paymentSucceeded/revertOnLogFailure.test.ts diff --git a/test/integration/quickbooks/paymentSucceeded/absorbedFeeFlagOff.test.ts b/test/integration/quickbooks/paymentSucceeded/absorbedFeeFlagOff.test.ts new file mode 100644 index 00000000..cddfc496 --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/absorbedFeeFlagOff.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' + +import paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook' +import { seedHealthyPortal, seedQBInvoiceSync } from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (absorbed-fee flag off)', () => { + const apis = setupPaymentSucceededTest() + + it('skips processing when the portal opts out of recording absorbed fees', async () => { + await seedHealthyPortal({ setting: { absorbedFeeFlag: false } }) + await seedQBInvoiceSync() + + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + expect(await db.select().from(QBSyncLog)).toHaveLength(0) + expect(apis.copilot.getInvoice).not.toHaveBeenCalled() + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() + expect(apis.intuit.createPurchase).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts new file mode 100644 index 00000000..a0142663 --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts @@ -0,0 +1,51 @@ +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 paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook' +import { + seedHealthyPortal, + seedQBInvoiceSync, + TEST_COPILOT_PAYMENT_ID, +} from '@test/helpers/seed' +import { createMockCopilotAPI } from '@test/helpers/mocks' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (Copilot returns no invoice)', () => { + const apis = setupPaymentSucceededTest(() => ({ + copilot: createMockCopilotAPI({ + getInvoice: vi.fn().mockResolvedValue(undefined), + }), + })) + + it('returns a 404 and does not call QuickBooks when Copilot cannot find the invoice', async () => { + await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) + await seedQBInvoiceSync() + + const res = await postWebhook(paymentSucceededPayload) + // The not-found throw escapes the inner try/catch (which only wraps the QB + // calls) and propagates to withErrorHandler, which surfaces it as 404. + expect(res.status).toBe(404) + + // No FAILED log is written — the throw happens before the outer catch block + // that writes sync logs for QB-layer errors. The claimed PENDING row is the + // only row in the table. + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + entityType: EntityType.PAYMENT, + eventType: EventType.SUCCEEDED, + status: LogStatus.PENDING, + }) + + expect(apis.intuit.createPurchase).not.toHaveBeenCalled() + expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/happyPath.test.ts b/test/integration/quickbooks/paymentSucceeded/happyPath.test.ts new file mode 100644 index 00000000..e12e35f3 --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/happyPath.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } 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 paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook' +import { + seedHealthyPortal, + seedQBInvoiceSync, + TEST_PORTAL_ID, + TEST_INVOICE_NUMBER, + TEST_COPILOT_INVOICE_ID, + TEST_COPILOT_PAYMENT_ID, + TEST_QB_PURCHASE_ID, + TEST_ASSET_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (absorbed-fee expense recorded)', () => { + const apis = setupPaymentSucceededTest() + + it('records the absorbed fee as a QuickBooks expense and logs the sync as successful', async () => { + await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) + await seedQBInvoiceSync() + + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + // For (payment, succeeded) the polymorphic `quickbooks_id` column holds the + // QBO Purchase id, not a payment id. See memory/project_qb_sync_logs_semantics.md. + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYMENT, + eventType: EventType.SUCCEEDED, + status: LogStatus.SUCCESS, + copilotId: TEST_COPILOT_PAYMENT_ID, + quickbooksId: TEST_QB_PURCHASE_ID, + feeAmount: '2500.00', + qbItemName: 'Assembly Fees', + remark: 'Absorbed fees', + }) + + expect(apis.copilot.getInvoice).toHaveBeenCalledWith(TEST_COPILOT_INVOICE_ID) + expect(apis.intuit.getAnAccount).toHaveBeenCalledTimes(2) // asset + expense + expect(apis.intuit.getAnAccount).toHaveBeenCalledWith( + undefined, + TEST_ASSET_ACCOUNT_REF, + true, + ) + expect(apis.intuit.getAnAccount).toHaveBeenCalledWith( + undefined, + TEST_EXPENSE_ACCOUNT_REF, + true, + ) + expect(apis.intuit.createPurchase).toHaveBeenCalledTimes(1) + expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() + + const [purchasePayload] = apis.intuit.createPurchase.mock.calls[0] + expect(purchasePayload).toMatchObject({ + PaymentType: 'Cash', + AccountRef: { value: TEST_ASSET_ACCOUNT_REF }, + DocNumber: TEST_INVOICE_NUMBER, + TxnDate: '2024-02-21', + }) + expect(purchasePayload.Line[0]).toMatchObject({ + DetailType: 'AccountBasedExpenseLineDetail', + Amount: 25, + AccountBasedExpenseLineDetail: { + AccountRef: { value: TEST_EXPENSE_ACCOUNT_REF }, + }, + }) + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/idempotency.test.ts b/test/integration/quickbooks/paymentSucceeded/idempotency.test.ts new file mode 100644 index 00000000..c0e863a8 --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/idempotency.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook' +import { + seedHealthyPortal, + seedQBInvoiceSync, + TEST_PORTAL_ID, + TEST_INVOICE_NUMBER, + TEST_COPILOT_PAYMENT_ID, + TEST_QB_PURCHASE_ID, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (same webhook delivered twice)', () => { + const apis = setupPaymentSucceededTest() + + it('processes the payment only once when a sync log for it already exists', async () => { + await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) + await seedQBInvoiceSync() + + // Simulate a prior successful delivery that already claimed and processed + // this payment. Inline insert mirrors the pattern in invoiceCreated/idempotency.test.ts. + await db.insert(QBSyncLog).values({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYMENT, + eventType: EventType.SUCCEEDED, + status: LogStatus.SUCCESS, + copilotId: TEST_COPILOT_PAYMENT_ID, + invoiceNumber: TEST_INVOICE_NUMBER, + quickbooksId: TEST_QB_PURCHASE_ID, + feeAmount: '2500.00', + qbItemName: 'Assembly Fees', + remark: 'Absorbed fees', + }) + + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + // Seeded log row stays exactly as it was; the second delivery did not + // overwrite it or insert a new row. + const logs = await db.select().from(QBSyncLog) + expect(logs).toHaveLength(1) + expect(logs[0].status).toBe(LogStatus.SUCCESS) + + expect(apis.copilot.getInvoice).not.toHaveBeenCalled() + expect(apis.intuit.createPurchase).not.toHaveBeenCalled() + expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts b/test/integration/quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts new file mode 100644 index 00000000..a123863d --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } 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 paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook' +import { seedHealthyPortal, TEST_COPILOT_PAYMENT_ID } from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (no local invoice mapping)', () => { + const apis = setupPaymentSucceededTest() + + it('marks the sync log as FAILED when the invoice has not been mirrored to QuickBooks yet', async () => { + // Healthy portal but NO seedQBInvoiceSync — local lookup must miss. + await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) + + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + entityType: EntityType.PAYMENT, + eventType: EventType.SUCCEEDED, + status: LogStatus.FAILED, + }) + expect(logs[0].errorMessage).toContain( + 'No invoice found in invoice sync table', + ) + + expect(apis.intuit.createPurchase).not.toHaveBeenCalled() + expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/noPlatformFee.test.ts b/test/integration/quickbooks/paymentSucceeded/noPlatformFee.test.ts new file mode 100644 index 00000000..2d9e966b --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/noPlatformFee.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' + +import paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook' +import { seedHealthyPortal, seedQBInvoiceSync } from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (no platform-paid fee)', () => { + const apis = setupPaymentSucceededTest() + + it.each([ + { + label: 'fee paid entirely by the client', + feeAmount: { paidByPlatform: 0, paidByClient: 2500 }, + }, + { + label: 'no fee data on the payment', + feeAmount: null, + }, + ])('skips processing when there is $label', async ({ feeAmount }) => { + // The handler short-circuits before the absorbedFeeFlag check, so the + // flag value shouldn't matter — but seeding the healthy default keeps + // the test representative of a normal portal. + await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) + await seedQBInvoiceSync() + + const payload = { + ...paymentSucceededPayload, + data: { ...paymentSucceededPayload.data, feeAmount }, + } + + const res = await postWebhook(payload) + expect(res.status).toBe(200) + + expect(await db.select().from(QBSyncLog)).toHaveLength(0) + expect(apis.copilot.getInvoice).not.toHaveBeenCalled() + expect(apis.intuit.createPurchase).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/qbCreatePurchaseFails.test.ts b/test/integration/quickbooks/paymentSucceeded/qbCreatePurchaseFails.test.ts new file mode 100644 index 00000000..6569edcd --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/qbCreatePurchaseFails.test.ts @@ -0,0 +1,50 @@ +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 paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook' +import { + seedHealthyPortal, + seedQBInvoiceSync, + TEST_COPILOT_PAYMENT_ID, +} from '@test/helpers/seed' +import { createMockIntuitAPI } from '@test/helpers/mocks' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (QuickBooks rejects the expense)', () => { + const apis = setupPaymentSucceededTest(() => ({ + intuit: createMockIntuitAPI({ + createPurchase: vi + .fn() + .mockRejectedValue(new Error('QuickBooks is on fire')), + }), + })) + + it('marks the sync log as FAILED and does not attempt to delete a purchase that was never created', async () => { + await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) + await seedQBInvoiceSync() + + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + entityType: EntityType.PAYMENT, + eventType: EventType.SUCCEEDED, + status: LogStatus.FAILED, + feeAmount: '2500.00', + }) + expect(logs[0].errorMessage).toContain('QuickBooks is on fire') + + expect(apis.intuit.createPurchase).toHaveBeenCalledTimes(1) + expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/revertOnLogFailure.test.ts b/test/integration/quickbooks/paymentSucceeded/revertOnLogFailure.test.ts new file mode 100644 index 00000000..00b8ceb3 --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/revertOnLogFailure.test.ts @@ -0,0 +1,64 @@ +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 { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' + +import paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook' +import { + seedHealthyPortal, + seedQBInvoiceSync, + TEST_COPILOT_PAYMENT_ID, + TEST_QB_PURCHASE_ID, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (inner log write fails after QB purchase created)', () => { + const apis = setupPaymentSucceededTest() + + it('deletes the created purchase and writes a FAILED log when the success-log write fails', async () => { + await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) + await seedQBInvoiceSync() + + // Fail only the first updateOrCreateQBSyncLog call (the SUCCESS log inside + // createExpenseForAbsorbedFees). mockImplementationOnce is one-shot, so the + // outer catch's FAILED log falls through to the real impl. try/finally + // restores the spy — leaked spies contaminate later tests under + // isolate:false. + const spy = vi + .spyOn(SyncLogService.prototype, 'updateOrCreateQBSyncLog') + .mockImplementationOnce(async () => { + throw new Error('Sync log write failed') + }) + + try { + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + // Revert was invoked with the just-created purchase id + sync token. + expect(apis.intuit.createPurchase).toHaveBeenCalledTimes(1) + expect(apis.intuit.deletePurchase).toHaveBeenCalledTimes(1) + expect(apis.intuit.deletePurchase).toHaveBeenCalledWith({ + Id: TEST_QB_PURCHASE_ID, + SyncToken: '0', + }) + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + entityType: EntityType.PAYMENT, + eventType: EventType.SUCCEEDED, + status: LogStatus.FAILED, + }) + expect(logs[0].errorMessage).toContain('Sync log write failed') + } finally { + spy.mockRestore() + } + }) +}) From 948c2fe4b28cda816534648512f5f3370ed30651 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 22 May 2026 16:21:10 +0545 Subject: [PATCH 4/6] test(OUT-3773): annotate getAnAccount positional args and fix prettier wrap Co-Authored-By: Claude Opus 4.7 (1M context) --- .../quickbooks/paymentSucceeded/happyPath.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/integration/quickbooks/paymentSucceeded/happyPath.test.ts b/test/integration/quickbooks/paymentSucceeded/happyPath.test.ts index e12e35f3..5f5be2c3 100644 --- a/test/integration/quickbooks/paymentSucceeded/happyPath.test.ts +++ b/test/integration/quickbooks/paymentSucceeded/happyPath.test.ts @@ -49,12 +49,14 @@ describe('POST /api/quickbooks/webhook — payment.succeeded (absorbed-fee expen remark: 'Absorbed fees', }) - expect(apis.copilot.getInvoice).toHaveBeenCalledWith(TEST_COPILOT_INVOICE_ID) + expect(apis.copilot.getInvoice).toHaveBeenCalledWith( + TEST_COPILOT_INVOICE_ID, + ) expect(apis.intuit.getAnAccount).toHaveBeenCalledTimes(2) // asset + expense expect(apis.intuit.getAnAccount).toHaveBeenCalledWith( - undefined, - TEST_ASSET_ACCOUNT_REF, - true, + undefined, // account name + TEST_ASSET_ACCOUNT_REF, // account id + true, // includeInactive ) expect(apis.intuit.getAnAccount).toHaveBeenCalledWith( undefined, From f00b91b9f0caeae68ceba39e2bda55d43f77bfed Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 22 May 2026 16:36:40 +0545 Subject: [PATCH 5/6] test(OUT-3773): document Name/AccountType as stubs in getAnAccount mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged that AccountType is always 'Income' regardless of which account id is passed. No current caller of getAnAccount inspects the field, so behavior is unchanged — adding a comment so a future test author knows to parameterize before relying on it. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/helpers/mocks.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 0fccf577..8773069f 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -84,6 +84,8 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { getAnItem: vi.fn().mockResolvedValue(undefined), // Echo the id back so callers (checkAndUpdateAccountStatus) get the ref // they asked for; name-only queries fall back to the income account. + // Name and AccountType are stubs — they don't vary by id because no + // current caller inspects them. Parameterize if a future test does. getAnAccount: vi .fn() .mockImplementation(async (_name?: string, id?: string) => ({ From 5be6a22476e6e1fa27bf8ec0cc0a858ce481c5cf Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 22 May 2026 16:37:54 +0545 Subject: [PATCH 6/6] test(OUT-3773): use TEST_COPILOT_INVOICE_ID in getInvoice mock default Greptile flagged that the id field was hardcoded as 'inv-cop-0001' while the rest of the file uses the named seed constant. The value matches today but would silently drift if the constant is renamed. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/helpers/mocks.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 8773069f..1e8da506 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -5,6 +5,7 @@ import { TEST_INCOME_ACCOUNT_REF, TEST_INTERNAL_USER_ID, TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, TEST_INVOICE_NUMBER, TEST_QB_PURCHASE_ID, } from './seed' @@ -64,7 +65,7 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) { getPayments: vi.fn().mockResolvedValue({ data: [] }), // payment.succeeded needs a real invoice object to proceed past the getInvoice guard (OUT-3773) getInvoice: vi.fn().mockResolvedValue({ - id: 'inv-cop-0001', + id: TEST_COPILOT_INVOICE_ID, number: TEST_INVOICE_NUMBER, }), ...overrides,