From 10e99e36439ef4fa3ceef46d4c4826ce4efd0986 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 13 Jul 2026 18:10:47 +0545 Subject: [PATCH 1/4] test(OUT-3954): add payment.succeeded fixture, xero mock defaults, and constants Shared harness for the payment.succeeded webhook suite: a webhook payload builder, absorbed-fee Xero mock defaults (account creation, expense lookup, bank transaction), and constants incl. TEST_FEE as the single fee source. Co-Authored-By: Claude Opus 4.8 --- test/fixtures/paymentSucceeded.webhook.ts | 29 ++++++++++++++++++++++ test/helpers/constants.ts | 17 +++++++++++++ test/helpers/mocks.ts | 30 +++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 test/fixtures/paymentSucceeded.webhook.ts diff --git a/test/fixtures/paymentSucceeded.webhook.ts b/test/fixtures/paymentSucceeded.webhook.ts new file mode 100644 index 0000000..3eb075f --- /dev/null +++ b/test/fixtures/paymentSucceeded.webhook.ts @@ -0,0 +1,29 @@ +import { + PaymentStatus, + type PaymentSucceededWebhookSchema, + ValidWebhookEvent, +} from '@invoice-sync/types' +import { TEST_FEE, TEST_INVOICE, TEST_PAYMENT } from '@test/helpers/constants' +import type { z } from 'zod' + +type PaymentSucceededWebhookInput = z.input +type PaymentSucceededData = PaymentSucceededWebhookInput['data'] + +// Builds a payment.succeeded payload. Fee is TEST_FEE (cents). Pass `dataOverrides` to vary. +export function buildPaymentSucceededWebhook( + dataOverrides: Partial = {}, +): PaymentSucceededWebhookInput { + return { + eventType: ValidWebhookEvent.PaymentSucceeded, + data: { + id: TEST_PAYMENT.id, + invoiceId: TEST_INVOICE.id, + status: PaymentStatus.SUCCEEDED, + paymentMethod: 'card', + brand: 'visa', + feeAmount: { paidByPlatform: TEST_FEE.cents, paidByClient: 0 }, + createdAt: '2026-01-01T00:00:00.000Z', + ...dataOverrides, + }, + } +} diff --git a/test/helpers/constants.ts b/test/helpers/constants.ts index ae62ac0..db6e41d 100644 --- a/test/helpers/constants.ts +++ b/test/helpers/constants.ts @@ -45,3 +45,20 @@ export const TEST_XERO_INVOICE = { id: '88888888-8888-4888-8888-888888888888', t export const TEST_SALES_ACCOUNT = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } // Xero payment id. Valid v4 uuid because synced_payments.xeroPaymentId is a uuid column. export const TEST_XERO_PAYMENT = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' } + +// payment.succeeded: the Copilot payment id and the Xero SPEND bank-txn id (v4 uuid). +export const TEST_PAYMENT = { id: 'test-payment-00000001' } +export const TEST_XERO_BANK_TXN = { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' } + +// Absorbed platform fee. `cents` is the single source; a non-round value catches +// a /100 bug. dollars/dollarsString derive it for the line item and sync-log columns. +const feeInCents = 237 +export const TEST_FEE = { + cents: feeInCents, + dollars: feeInCents / 100, + dollarsString: String(feeInCents / 100), +} + +// Absorbed-fee asset + expense accounts. Only id is used; region codes asserted inline. +export const TEST_ASSET_ACCOUNT = { id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' } +export const TEST_EXPENSE_ACCOUNT = { id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' } diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index a5b44d4..81545e0 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -1,9 +1,12 @@ import { + TEST_ASSET_ACCOUNT, TEST_CLIENT, TEST_COMPANY, + TEST_EXPENSE_ACCOUNT, TEST_INVOICE, TEST_PORTAL, TEST_SALES_ACCOUNT, + TEST_XERO_BANK_TXN, TEST_XERO_CONTACT, TEST_XERO_INVOICE, TEST_XERO_ITEM, @@ -132,6 +135,33 @@ export function createMockXeroAPI(overrides: XeroAPIOverrides = {}) { invoiceID: TEST_XERO_INVOICE.id, status: 'DELETED', }), + // payment.succeeded: getAccounts=[] so accounts are created; no existing expense. + createFixedAssetsAccount: vi.fn( + async (_tenantId: string, account: { code: string; name: string }) => ({ + accountID: TEST_ASSET_ACCOUNT.id, + code: account.code, + name: account.name, + type: 'BANK', + status: 'ACTIVE', + }), + ), + createExpenseAccount: vi.fn( + async (_tenantId: string, account: { code: string; name: string }) => ({ + accountID: TEST_EXPENSE_ACCOUNT.id, + code: account.code, + name: account.name, + type: 'EXPENSE', + status: 'ACTIVE', + enablePaymentsToAccount: true, + }), + ), + // No existing expense by reference or legacy invoice-id match. + findBankTransactionByReference: vi.fn().mockResolvedValue(undefined), + findLegacyExpenseByInvoice: vi.fn().mockResolvedValue(undefined), + createBankTransaction: vi.fn().mockResolvedValue({ + bankTransactionID: TEST_XERO_BANK_TXN.id, + status: 'AUTHORISED', + }), ...overrides, } } From 5e0a454ce379498114bd2d38e13064f28cc26429 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 13 Jul 2026 18:10:49 +0545 Subject: [PATCH 2/4] test(OUT-3954): cover payment.succeeded happy path and skip gates Happy path (US+AU) asserts the SPEND expense, synced_payments row, and expense sync log. Skip gates: isSyncEnabled (controller), addAbsorbedFees (handler), and unsupported region. Co-Authored-By: Claude Opus 4.8 --- .../absorbedFeesDisabled.test.ts | 30 +++++++ .../paymentSucceeded/happyPath.test.ts | 86 +++++++++++++++++++ .../paymentSucceeded/syncDisabled.test.ts | 25 ++++++ .../unsupportedRegion.test.ts | 25 ++++++ 4 files changed, 166 insertions(+) create mode 100644 test/integration/webhook/paymentSucceeded/absorbedFeesDisabled.test.ts create mode 100644 test/integration/webhook/paymentSucceeded/happyPath.test.ts create mode 100644 test/integration/webhook/paymentSucceeded/syncDisabled.test.ts create mode 100644 test/integration/webhook/paymentSucceeded/unsupportedRegion.test.ts diff --git a/test/integration/webhook/paymentSucceeded/absorbedFeesDisabled.test.ts b/test/integration/webhook/paymentSucceeded/absorbedFeesDisabled.test.ts new file mode 100644 index 0000000..9e48602 --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/absorbedFeesDisabled.test.ts @@ -0,0 +1,30 @@ +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — payment.succeeded absorbed fees disabled', () => { + const apis = setupWebhookTest() + + it('skips expense creation when addAbsorbedFees is false', async () => { + // addAbsorbedFees defaults to false in the seed; sync is otherwise enabled. + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(200) + + expect(apis.xero.createBankTransaction).not.toHaveBeenCalled() + expect(await db.select().from(syncedPayments)).toHaveLength(0) + const expenseLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.entityType, SyncEntityType.EXPENSE)) + expect(expenseLogs).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/paymentSucceeded/happyPath.test.ts b/test/integration/webhook/paymentSucceeded/happyPath.test.ts new file mode 100644 index 0000000..89bea2c --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/happyPath.test.ts @@ -0,0 +1,86 @@ +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { + TEST_FEE, + TEST_INVOICE, + TEST_PAYMENT, + TEST_PORTAL, + TEST_XERO_BANK_TXN, + TEST_XERO_INVOICE, +} from '@test/helpers/constants' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { and, eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +// Absorbed-fee account codes; differ US vs AU. +const REGIONS = [ + { countryCode: 'US', bankCode: '2001', feesCode: '6041' }, + { countryCode: 'AU', bankCode: '9010', feesCode: '9020' }, +] as const + +describe.each(REGIONS)('POST /api/webhook — payment.succeeded [$countryCode]', (region) => { + const apis = setupWebhookTest() + + it('creates a SPEND expense, records synced_payments, and logs the expense', async () => { + await seedConnectedPortal({ + settings: { countryCode: region.countryCode, addAbsorbedFees: true }, + }) + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(200) + + // One SPEND transaction referencing the payment id, fee from TEST_FEE, region codes. + expect(apis.xero.createBankTransaction).toHaveBeenCalledTimes(1) + const [tenantId, payload, idempotencyKey] = apis.xero.createBankTransaction.mock.calls[0] + expect(tenantId).toBe(TEST_PORTAL.tenantId) + expect(idempotencyKey).toBe(TEST_PAYMENT.id) + expect(payload).toMatchObject({ + type: 'SPEND', + reference: TEST_PAYMENT.id, + bankAccount: { code: region.bankCode }, + contact: { name: 'Assembly Processing Fees' }, + lineItems: [{ accountCode: region.feesCode, quantity: 1, unitAmount: TEST_FEE.dollars }], + }) + + // An EXPENSE synced_payments row keyed by the Copilot payment id. + const payments = await db.select().from(syncedPayments) + expect(payments).toHaveLength(1) + expect(payments[0]).toMatchObject({ + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + copilotInvoiceId: TEST_INVOICE.id, + xeroInvoiceId: TEST_XERO_INVOICE.id, + xeroPaymentId: TEST_XERO_BANK_TXN.id, + copilotPaymentId: TEST_PAYMENT.id, + type: 'expense', + }) + + // An expense created-success sync log carrying the fee amount. + const expenseLogs = await db + .select() + .from(syncLogs) + .where( + and( + eq(syncLogs.entityType, SyncEntityType.EXPENSE), + eq(syncLogs.eventType, SyncEventType.CREATED), + ), + ) + expect(expenseLogs).toHaveLength(1) + expect(expenseLogs[0]).toMatchObject({ + status: SyncStatus.SUCCESS, + copilotId: TEST_INVOICE.id, + xeroId: TEST_XERO_BANK_TXN.id, + amount: TEST_FEE.dollarsString, + feeAmount: TEST_FEE.dollarsString, + }) + + // No failure recorded on the happy path. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/paymentSucceeded/syncDisabled.test.ts b/test/integration/webhook/paymentSucceeded/syncDisabled.test.ts new file mode 100644 index 0000000..59d7807 --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/syncDisabled.test.ts @@ -0,0 +1,25 @@ +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — payment.succeeded sync disabled', () => { + const apis = setupWebhookTest() + + it('acks and does nothing when isSyncEnabled is false', async () => { + // addAbsorbedFees is on, so only the controller-level isSyncEnabled gate stops it. + await seedConnectedPortal({ settings: { isSyncEnabled: false, addAbsorbedFees: true } }) + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(200) + + expect(apis.xero.createBankTransaction).not.toHaveBeenCalled() + expect(await db.select().from(syncedPayments)).toHaveLength(0) + expect(await db.select().from(syncLogs)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/paymentSucceeded/unsupportedRegion.test.ts b/test/integration/webhook/paymentSucceeded/unsupportedRegion.test.ts new file mode 100644 index 0000000..33d9eb1 --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/unsupportedRegion.test.ts @@ -0,0 +1,25 @@ +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — payment.succeeded unsupported region', () => { + const apis = setupWebhookTest() + + it('acks and skips when the Xero region is unsupported', async () => { + // GB is unsupported, so getRegionConfig returns null and the handler skips. + await seedConnectedPortal({ settings: { countryCode: 'GB', addAbsorbedFees: true } }) + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(200) + + expect(apis.xero.createBankTransaction).not.toHaveBeenCalled() + expect(await db.select().from(syncedPayments)).toHaveLength(0) + expect(await db.select().from(syncLogs)).toHaveLength(0) + }) +}) From 81aeb10239d5ca0f7a58d91b11c9d50c15f2df0c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 13 Jul 2026 18:10:51 +0545 Subject: [PATCH 3/4] test(OUT-3954): cover payment.succeeded reuse, idempotency, and race-guard paths Replay guard skip, both reuse arms of the transaction-resolution chain (by-reference and legacy), the concurrent-duplicate insert race guard, and the missing-Xero-invoice recreate path. Co-Authored-By: Claude Opus 4.8 --- .../concurrentDuplicate.test.ts | 36 +++++++++ .../paymentSucceeded/expenseReuse.test.ts | 77 +++++++++++++++++++ .../paymentSucceeded/idempotency.test.ts | 38 +++++++++ .../missingXeroInvoice.test.ts | 56 ++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 test/integration/webhook/paymentSucceeded/concurrentDuplicate.test.ts create mode 100644 test/integration/webhook/paymentSucceeded/expenseReuse.test.ts create mode 100644 test/integration/webhook/paymentSucceeded/idempotency.test.ts create mode 100644 test/integration/webhook/paymentSucceeded/missingXeroInvoice.test.ts diff --git a/test/integration/webhook/paymentSucceeded/concurrentDuplicate.test.ts b/test/integration/webhook/paymentSucceeded/concurrentDuplicate.test.ts new file mode 100644 index 0000000..086409d --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/concurrentDuplicate.test.ts @@ -0,0 +1,36 @@ +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { TEST_XERO_BANK_TXN } from '@test/helpers/constants' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncedPayment } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — payment.succeeded concurrent duplicate', () => { + const apis = setupWebhookTest() + + it('returns the transaction but skips the sync log when the insert no-ops', async () => { + await seedConnectedPortal({ settings: { addAbsorbedFees: true } }) + await seedSyncedInvoice({ status: 'success' }) + // Seed a row with the same xeroPaymentId. It dodges the EXPENSE replay guard + // but collides on the unique index, so the insert no-ops — the race-guard branch. + await seedSyncedPayment({ xeroPaymentId: TEST_XERO_BANK_TXN.id }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(200) + + // A create was attempted (guard missed), but the insert conflicted. + expect(apis.xero.createBankTransaction).toHaveBeenCalledTimes(1) + + // Still just the seeded row; no expense sync log because the insert no-op'd. + expect(await db.select().from(syncedPayments)).toHaveLength(1) + const expenseLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.entityType, SyncEntityType.EXPENSE)) + expect(expenseLogs).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/paymentSucceeded/expenseReuse.test.ts b/test/integration/webhook/paymentSucceeded/expenseReuse.test.ts new file mode 100644 index 0000000..1c4102c --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/expenseReuse.test.ts @@ -0,0 +1,77 @@ +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { TEST_FEE, TEST_PAYMENT, TEST_XERO_BANK_TXN } from '@test/helpers/constants' +import { createMockXeroAPI } from '@test/helpers/mocks' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest, type WebhookTestHandle } from '@test/helpers/webhookTestSetup' +import { and, eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, SyncEventType, syncLogs } from '@/db/schema/syncLogs.schema' + +const foundExpense = { bankTransactionID: TEST_XERO_BANK_TXN.id, status: 'AUTHORISED' } + +// The two reuse arms of the resolution chain: findBankTransactionByReference ?? +// findLegacyExpenseByInvoice ?? createBankTransaction. A hit on either reuses the +// found expense instead of creating a new transaction; only the lookup differs. +const CASES = [ + { + label: 'by reference', + override: () => ({ findBankTransactionByReference: vi.fn().mockResolvedValue(foundExpense) }), + verifyLookups: (apis: WebhookTestHandle) => { + expect(apis.xero.findBankTransactionByReference).toHaveBeenCalledTimes(1) + // Reference hit short-circuits before the legacy lookup. + expect(apis.xero.findLegacyExpenseByInvoice).not.toHaveBeenCalled() + }, + }, + { + label: 'legacy by invoice', + override: () => ({ + findLegacyExpenseByInvoice: vi + .fn() + .mockResolvedValue({ ...foundExpense, total: TEST_FEE.dollars }), + }), + verifyLookups: (apis: WebhookTestHandle) => { + // Reference lookup ran first and missed, so the chain fell through to legacy. + expect(apis.xero.findBankTransactionByReference).toHaveBeenCalledTimes(1) + expect(apis.xero.findLegacyExpenseByInvoice).toHaveBeenCalledTimes(1) + }, + }, +] as const + +describe.each(CASES)( + 'POST /api/webhook — payment.succeeded reuses an expense ($label)', + (testCase) => { + const apis = setupWebhookTest(() => ({ xero: createMockXeroAPI(testCase.override()) })) + + it('records the found transaction and skips createBankTransaction', async () => { + await seedConnectedPortal({ settings: { addAbsorbedFees: true } }) + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(200) + + testCase.verifyLookups(apis) + expect(apis.xero.createBankTransaction).not.toHaveBeenCalled() + + const payments = await db.select().from(syncedPayments) + expect(payments).toHaveLength(1) + expect(payments[0]).toMatchObject({ + xeroPaymentId: TEST_XERO_BANK_TXN.id, + copilotPaymentId: TEST_PAYMENT.id, + type: 'expense', + }) + const expenseLogs = await db + .select() + .from(syncLogs) + .where( + and( + eq(syncLogs.entityType, SyncEntityType.EXPENSE), + eq(syncLogs.eventType, SyncEventType.CREATED), + ), + ) + expect(expenseLogs).toHaveLength(1) + }) + }, +) diff --git a/test/integration/webhook/paymentSucceeded/idempotency.test.ts b/test/integration/webhook/paymentSucceeded/idempotency.test.ts new file mode 100644 index 0000000..a58c92a --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/idempotency.test.ts @@ -0,0 +1,38 @@ +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { TEST_PAYMENT } from '@test/helpers/constants' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncedPayment } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { PaymentUserType, syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — payment.succeeded idempotency', () => { + const apis = setupWebhookTest() + + it('skips creating an expense when one already exists for the payment', async () => { + await seedConnectedPortal({ settings: { addAbsorbedFees: true } }) + await seedSyncedInvoice({ status: 'success' }) + // An EXPENSE row already recorded for this Copilot payment id. + await seedSyncedPayment({ + type: PaymentUserType.EXPENSE, + copilotPaymentId: TEST_PAYMENT.id, + }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(200) + + // Replay guard short-circuits before any Xero write. + expect(apis.xero.createBankTransaction).not.toHaveBeenCalled() + + // Still exactly one payment row, and no expense sync log was added. + expect(await db.select().from(syncedPayments)).toHaveLength(1) + const expenseLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.entityType, SyncEntityType.EXPENSE)) + expect(expenseLogs).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/paymentSucceeded/missingXeroInvoice.test.ts b/test/integration/webhook/paymentSucceeded/missingXeroInvoice.test.ts new file mode 100644 index 0000000..4e28ee0 --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/missingXeroInvoice.test.ts @@ -0,0 +1,56 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { TEST_INVOICE, TEST_XERO_INVOICE } from '@test/helpers/constants' +import { createMockCopilotAPI } from '@test/helpers/mocks' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { and, eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, SyncEventType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — payment.succeeded missing Xero invoice', () => { + // No xeroInvoiceId, so the service recreates the Xero invoice before the expense. + const apis = setupWebhookTest(() => ({ + copilot: createMockCopilotAPI({ + getInvoice: vi.fn().mockResolvedValue(buildInvoiceCreatedWebhook().data), + }), + })) + + it('creates the missing Xero invoice, then records the expense', async () => { + await seedConnectedPortal({ settings: { addAbsorbedFees: true } }) + await seedSyncedInvoice({ status: 'pending', xeroInvoiceId: null }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(200) + + expect(apis.copilot.getInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.createInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.createBankTransaction).toHaveBeenCalledTimes(1) + + // Invoice row now mapped to Xero and marked success. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ + copilotInvoiceId: TEST_INVOICE.id, + xeroInvoiceId: TEST_XERO_INVOICE.id, + status: 'success', + }) + + // Expense recorded and an expense sync log written. + expect(await db.select().from(syncedPayments)).toHaveLength(1) + const expenseLogs = await db + .select() + .from(syncLogs) + .where( + and( + eq(syncLogs.entityType, SyncEntityType.EXPENSE), + eq(syncLogs.eventType, SyncEventType.CREATED), + ), + ) + expect(expenseLogs).toHaveLength(1) + }) +}) From 906b752215f1bf71cd37a337abde1ed977b0e7b3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 13 Jul 2026 18:10:53 +0545 Subject: [PATCH 4/4] test(OUT-3954): cover payment.succeeded failure paths A missing Xero invoice and a failing createBankTransaction both wrap to a 500 with a failed EXPENSE sync log and a failed_syncs row keyed by payment id. Co-Authored-By: Claude Opus 4.8 --- .../paymentSucceeded/invoiceNotFound.test.ts | 57 ++++++++++++++++++ .../xeroCreateBankTransactionFails.test.ts | 58 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 test/integration/webhook/paymentSucceeded/invoiceNotFound.test.ts create mode 100644 test/integration/webhook/paymentSucceeded/xeroCreateBankTransactionFails.test.ts diff --git a/test/integration/webhook/paymentSucceeded/invoiceNotFound.test.ts b/test/integration/webhook/paymentSucceeded/invoiceNotFound.test.ts new file mode 100644 index 0000000..e3a365c --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/invoiceNotFound.test.ts @@ -0,0 +1,57 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { TEST_FEE, TEST_INVOICE, TEST_PAYMENT } from '@test/helpers/constants' +import { createMockXeroAPI } from '@test/helpers/mocks' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { and, eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — payment.succeeded Xero invoice not found', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + getInvoiceById: vi.fn().mockResolvedValue(undefined), + }), + })) + + it('wraps the NOT_FOUND as a 500 and records the failure', async () => { + await seedConnectedPortal({ settings: { addAbsorbedFees: true } }) + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + // The NOT_FOUND is caught and rethrown as 500, unlike invoice.paid's direct 404. + expect(res.status).toBe(500) + + expect(apis.xero.createBankTransaction).not.toHaveBeenCalled() + expect(await db.select().from(syncedPayments)).toHaveLength(0) + + // A failed expense sync log is written from the failedSyncLogPayload. + const expenseLogs = await db + .select() + .from(syncLogs) + .where( + and( + eq(syncLogs.entityType, SyncEntityType.EXPENSE), + eq(syncLogs.status, SyncStatus.FAILED), + ), + ) + expect(expenseLogs).toHaveLength(1) + expect(expenseLogs[0]).toMatchObject({ + copilotId: TEST_INVOICE.id, + feeAmount: TEST_FEE.dollarsString, + }) + + // A failed_syncs row is recorded for retry, keyed by the payment id. + const failed = await db.select().from(failedSyncs) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: ValidWebhookEvent.PaymentSucceeded, + resourceId: TEST_PAYMENT.id, + }) + }) +}) diff --git a/test/integration/webhook/paymentSucceeded/xeroCreateBankTransactionFails.test.ts b/test/integration/webhook/paymentSucceeded/xeroCreateBankTransactionFails.test.ts new file mode 100644 index 0000000..cd62619 --- /dev/null +++ b/test/integration/webhook/paymentSucceeded/xeroCreateBankTransactionFails.test.ts @@ -0,0 +1,58 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook' +import { TEST_FEE, TEST_INVOICE, TEST_PAYMENT } from '@test/helpers/constants' +import { createMockXeroAPI } from '@test/helpers/mocks' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { and, eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — payment.succeeded Xero failure', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + createBankTransaction: vi.fn().mockRejectedValue(new Error('Xero 500: transaction rejected')), + }), + })) + + it('records failure in sync_logs and failed_syncs, and returns 500', async () => { + await seedConnectedPortal({ settings: { addAbsorbedFees: true } }) + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaymentSucceededWebhook()) + expect(res.status).toBe(500) + + expect(apis.xero.createBankTransaction).toHaveBeenCalledTimes(1) + + // No payment row is written on failure. + expect(await db.select().from(syncedPayments)).toHaveLength(0) + + // A failed expense sync log is written from the failedSyncLogPayload. + const expenseLogs = await db + .select() + .from(syncLogs) + .where( + and( + eq(syncLogs.entityType, SyncEntityType.EXPENSE), + eq(syncLogs.status, SyncStatus.FAILED), + ), + ) + expect(expenseLogs).toHaveLength(1) + expect(expenseLogs[0]).toMatchObject({ + copilotId: TEST_INVOICE.id, + feeAmount: TEST_FEE.dollarsString, + }) + + // A failed_syncs row is recorded for retry, keyed by the payment id. + const failed = await db.select().from(failedSyncs) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: ValidWebhookEvent.PaymentSucceeded, + resourceId: TEST_PAYMENT.id, + }) + }) +})