From 685d6038a0a21b0aec567cb8eaeea7a66d5b1eb7 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 8 Jul 2026 18:17:40 +0545 Subject: [PATCH 1/7] test(OUT-3951): add invoice.paid happy-path integration tests for US and AU Extend the webhook test harness for the invoice.paid flow: getInvoiceById and markInvoicePaid mock defaults, a paidInvoice webhook fixture, and seedSyncedPayment / seedSyncLog helpers. The happy path is parametrized over US and AU and asserts the region sales code and invoice total flow into markInvoicePaid, a synced_payments row is written, and a paid success log is recorded. Co-Authored-By: Claude Opus 4.8 --- test/fixtures/paidInvoice.webhook.ts | 20 +++++ test/helpers/constants.ts | 6 +- test/helpers/mocks.ts | 10 +++ test/helpers/seed.ts | 51 ++++++++++++ .../webhook/invoicePaid/happyPath.test.ts | 81 +++++++++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/paidInvoice.webhook.ts create mode 100644 test/integration/webhook/invoicePaid/happyPath.test.ts diff --git a/test/fixtures/paidInvoice.webhook.ts b/test/fixtures/paidInvoice.webhook.ts new file mode 100644 index 0000000..8dd0863 --- /dev/null +++ b/test/fixtures/paidInvoice.webhook.ts @@ -0,0 +1,20 @@ +import { type InvoicePaidWebhookSchema, ValidWebhookEvent } from '@invoice-sync/types' +import { TEST_INVOICE } from '@test/helpers/constants' +import type { z } from 'zod' + +type PaidInvoiceWebhookInput = z.input +type PaidInvoiceData = PaidInvoiceWebhookInput['data'] + +// Builds an invoice.paid webhook payload. The payload is just { id }; pass +// `dataOverrides` to vary the invoice id. +export function buildPaidInvoiceWebhook( + dataOverrides: Partial = {}, +): PaidInvoiceWebhookInput { + return { + eventType: ValidWebhookEvent.InvoicePaid, + data: { + id: TEST_INVOICE.id, + ...dataOverrides, + }, + } +} diff --git a/test/helpers/constants.ts b/test/helpers/constants.ts index 5eba9da..ae62ac0 100644 --- a/test/helpers/constants.ts +++ b/test/helpers/constants.ts @@ -39,5 +39,9 @@ export const TEST_COMPANY = { id: '66666666-6666-4666-8666-666666666666' } // are uuid columns. export const TEST_INVOICE = { id: 'test-invoice-00000001', number: 'INV-0001' } export const TEST_XERO_CONTACT = { id: '77777777-7777-4777-8777-777777777777' } -export const TEST_XERO_INVOICE = { id: '88888888-8888-4888-8888-888888888888' } +// `total` is the dollar total invoice.paid passes to markInvoicePaid; a non-round +// value catches an accidental cents/dollars conversion or rounding bug. +export const TEST_XERO_INVOICE = { id: '88888888-8888-4888-8888-888888888888', total: 108.25 } 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' } diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 597b48b..063aa3d 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -1,11 +1,13 @@ import { TEST_CLIENT, TEST_COMPANY, + TEST_INVOICE, TEST_PORTAL, TEST_SALES_ACCOUNT, TEST_XERO_CONTACT, TEST_XERO_INVOICE, TEST_XERO_ITEM, + TEST_XERO_PAYMENT, } from '@test/helpers/constants' import { type Mock, vi } from 'vitest' import { CopilotAPI } from '@/lib/copilot/CopilotAPI' @@ -112,6 +114,14 @@ export function createMockXeroAPI(overrides: XeroAPIOverrides = {}) { invoiceNumber: invoice.invoiceNumber, status: 'AUTHORISED', })), + // invoice.paid: fetch the Xero invoice, then create the payment against it. + getInvoiceById: vi.fn().mockResolvedValue({ + invoiceID: TEST_XERO_INVOICE.id, + invoiceNumber: TEST_INVOICE.number, + status: 'AUTHORISED', + total: TEST_XERO_INVOICE.total, + }), + markInvoicePaid: vi.fn().mockResolvedValue({ paymentID: TEST_XERO_PAYMENT.id }), ...overrides, } } diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index 4009b32..942ff7a 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -8,6 +8,7 @@ import { TEST_XERO_CONTACT, TEST_XERO_INVOICE, TEST_XERO_ITEM, + TEST_XERO_PAYMENT, } from '@test/helpers/constants' import type { InferInsertModel } from 'drizzle-orm' import type { TokenSet } from 'xero-node' @@ -16,6 +17,8 @@ import { settings } from '@/db/schema/settings.schema' import { SyncedContactUserType, syncedContacts } from '@/db/schema/syncedContacts.schema' import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' import { syncedItems } from '@/db/schema/syncedItems.schema' +import { PaymentUserType, syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' import { xeroConnections } from '@/db/schema/xeroConnections.schema' type ConnectionOverrides = Partial> @@ -134,3 +137,51 @@ export async function seedSyncedContact(overrides: SyncedContactOverrides = {}) .returning() return row } + +type SyncedPaymentOverrides = Partial> + +const baseSyncedPayment: InferInsertModel = { + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + copilotInvoiceId: TEST_INVOICE.id, + xeroInvoiceId: TEST_XERO_INVOICE.id, + copilotPaymentId: null, + xeroPaymentId: TEST_XERO_PAYMENT.id, + type: PaymentUserType.PAYMENT, +} + +// Seeds a synced_payments row (an invoice payment) for the idempotency case. +export async function seedSyncedPayment(overrides: SyncedPaymentOverrides = {}) { + const [row] = await db + .insert(syncedPayments) + .values({ ...baseSyncedPayment, ...overrides }) + .returning() + return row +} + +type SyncLogOverrides = Partial> + +const baseSyncLog: InferInsertModel = { + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + syncDate: new Date(), + entityType: SyncEntityType.INVOICE, + eventType: SyncEventType.CREATED, + status: SyncStatus.SUCCESS, + copilotId: TEST_INVOICE.id, + xeroId: TEST_XERO_INVOICE.id, + invoiceNumber: TEST_INVOICE.number, + customerName: `${TEST_CLIENT.givenName} ${TEST_CLIENT.familyName}`, + customerEmail: TEST_CLIENT.email, +} + +// Seeds a prior invoice.created success sync log. invoice.paid inherits its +// fields (entityType, invoiceNumber, customerName) into the paid log, so the +// paid-log INSERT has the NOT-NULL entityType it needs. +export async function seedSyncLog(overrides: SyncLogOverrides = {}) { + const [row] = await db + .insert(syncLogs) + .values({ ...baseSyncLog, ...overrides }) + .returning() + return row +} diff --git a/test/integration/webhook/invoicePaid/happyPath.test.ts b/test/integration/webhook/invoicePaid/happyPath.test.ts new file mode 100644 index 0000000..51357ae --- /dev/null +++ b/test/integration/webhook/invoicePaid/happyPath.test.ts @@ -0,0 +1,81 @@ +import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.webhook' +import { + TEST_CLIENT, + TEST_INVOICE, + TEST_PORTAL, + TEST_XERO_INVOICE, + TEST_XERO_PAYMENT, +} from '@test/helpers/constants' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncLog } 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 { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +// Region-specific expectation: the sales-account code posted with the payment. +// With getAccounts=[] the stored account isn't found, so resolution falls +// through to the region-default account, whose code differs US vs AU. +const REGIONS = [ + { countryCode: 'US', salesCode: '4000' }, + { countryCode: 'AU', salesCode: '9000' }, +] as const + +describe.each(REGIONS)('POST /api/webhook — invoice.paid [$countryCode]', (region) => { + const apis = setupWebhookTest() + + it('creates a Xero payment, records synced_payments, and logs the paid event', async () => { + await seedConnectedPortal({ settings: { countryCode: region.countryCode } }) + await seedSyncedInvoice({ status: 'success' }) + await seedSyncLog() + + const res = await postWebhook(buildPaidInvoiceWebhook()) + expect(res.status).toBe(200) + + // Xero invoice fetched, then the payment created against the region's sales code. + expect(apis.xero.getInvoiceById).toHaveBeenCalledTimes(1) + expect(apis.xero.markInvoicePaid).toHaveBeenCalledTimes(1) + const [tenantId, xeroInvoiceId, amount, salesCode] = apis.xero.markInvoicePaid.mock.calls[0] + expect(tenantId).toBe(TEST_PORTAL.tenantId) + expect(xeroInvoiceId).toBe(TEST_XERO_INVOICE.id) + expect(amount).toBe(TEST_XERO_INVOICE.total) + expect(salesCode).toBe(region.salesCode) + + // synced_payments row created for the invoice payment. + 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_PAYMENT.id, + copilotPaymentId: null, + type: 'payment', + }) + + // Invoice stays success. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'success' }) + + // A paid success sync log written, carrying the created log's invoice fields. + const paidLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.PAID)) + expect(paidLogs).toHaveLength(1) + expect(paidLogs[0]).toMatchObject({ + status: SyncStatus.SUCCESS, + invoiceNumber: TEST_INVOICE.number, + customerName: `${TEST_CLIENT.givenName} ${TEST_CLIENT.familyName}`, + }) + + // No failure recorded on the happy path. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From 2c75ed0292088ffac887b332ca33968c483660e0 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 8 Jul 2026 18:17:43 +0545 Subject: [PATCH 2/7] test(OUT-3951): assert invoice.paid skips xero when already paid A success invoice with an existing synced_payments row short-circuits before markInvoicePaid, adds no new payment row, and writes no new paid sync log. Co-Authored-By: Claude Opus 4.8 --- .../webhook/invoicePaid/idempotency.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 test/integration/webhook/invoicePaid/idempotency.test.ts diff --git a/test/integration/webhook/invoicePaid/idempotency.test.ts b/test/integration/webhook/invoicePaid/idempotency.test.ts new file mode 100644 index 0000000..829235d --- /dev/null +++ b/test/integration/webhook/invoicePaid/idempotency.test.ts @@ -0,0 +1,34 @@ +import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.webhook' +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 { SyncEventType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.paid idempotency', () => { + const apis = setupWebhookTest() + + it('skips marking paid when the invoice already has a payment', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + await seedSyncedPayment() + + const res = await postWebhook(buildPaidInvoiceWebhook()) + expect(res.status).toBe(200) + + // Already-paid invoice short-circuits before creating another payment. + // (getInvoiceById runs earlier, so it is not asserted here.) + expect(apis.xero.markInvoicePaid).not.toHaveBeenCalled() + + // Still exactly one payment row, and no paid sync log was added. + expect(await db.select().from(syncedPayments)).toHaveLength(1) + const paidLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.PAID)) + expect(paidLogs).toHaveLength(0) + }) +}) From b397d9c14b17a1ca0283fa2446da342f0dfbf292 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 8 Jul 2026 18:17:45 +0545 Subject: [PATCH 3/7] test(OUT-3951): assert invoice.paid short-circuits when sync disabled When isSyncEnabled is false the controller returns 200 without invoking the handler, so no Xero call is made and no payment row is written. Co-Authored-By: Claude Opus 4.8 --- .../webhook/invoicePaid/syncDisabled.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/integration/webhook/invoicePaid/syncDisabled.test.ts diff --git a/test/integration/webhook/invoicePaid/syncDisabled.test.ts b/test/integration/webhook/invoicePaid/syncDisabled.test.ts new file mode 100644 index 0000000..9ddf74c --- /dev/null +++ b/test/integration/webhook/invoicePaid/syncDisabled.test.ts @@ -0,0 +1,24 @@ +import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.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' + +describe('POST /api/webhook — invoice.paid sync disabled', () => { + const apis = setupWebhookTest() + + it('short-circuits at the controller when sync is disabled', async () => { + await seedConnectedPortal({ settings: { isSyncEnabled: false } }) + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaidInvoiceWebhook()) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ message: 'Sync is disabled for this workspace' }) + + // Handler never runs. + expect(apis.xero.markInvoicePaid).not.toHaveBeenCalled() + expect(await db.select().from(syncedPayments)).toHaveLength(0) + }) +}) From 7731015197aba2c377481a4626ef7a004c74d5de Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 8 Jul 2026 18:17:59 +0545 Subject: [PATCH 4/7] test(OUT-3951): assert invoice.paid records failed_syncs on markInvoicePaid failure When markInvoicePaid throws, the flow writes a failed paid sync log and a failed_syncs row, returns 500, and leaves the invoice row untouched with no payment recorded. Co-Authored-By: Claude Opus 4.8 --- .../invoicePaid/xeroMarkPaidFails.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts diff --git a/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts b/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts new file mode 100644 index 0000000..948c6bb --- /dev/null +++ b/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts @@ -0,0 +1,57 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.webhook' +import { TEST_INVOICE } 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 { eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.paid xero failure', () => { + // Override markInvoicePaid to throw; a fresh factory per test keeps it isolated. + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + markInvoicePaid: vi.fn().mockRejectedValue(new Error('Xero 500: payment rejected')), + }), + })) + + it('records failure in sync_logs and failed_syncs, and returns 500', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaidInvoiceWebhook()) + expect(res.status).toBe(500) + + expect(apis.xero.markInvoicePaid).toHaveBeenCalledTimes(1) + + // No payment row is written on failure. + expect(await db.select().from(syncedPayments)).toHaveLength(0) + + // The invoice row is untouched by the failed payment (stays as-created). + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'success' }) + + // A failed paid sync log is written. + const paidLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.PAID)) + expect(paidLogs).toHaveLength(1) + expect(paidLogs[0].status).toBe(SyncStatus.FAILED) + + // A failed_syncs row is recorded for retry. + const failed = await db.select().from(failedSyncs) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: ValidWebhookEvent.InvoicePaid, + resourceId: TEST_INVOICE.id, + }) + }) +}) From 69a13a10fd4607b302455180aa0a63578d62345f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 8 Jul 2026 18:18:02 +0545 Subject: [PATCH 5/7] test(OUT-3951): assert invoice.paid creates the missing xero invoice then pays When the synced row has no xeroInvoiceId, the flow re-fetches the Copilot invoice, creates it in Xero, then marks it paid: the row ends mapped and success, with a payment row and a paid success log. Co-Authored-By: Claude Opus 4.8 --- .../invoicePaid/missingXeroInvoice.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/integration/webhook/invoicePaid/missingXeroInvoice.test.ts diff --git a/test/integration/webhook/invoicePaid/missingXeroInvoice.test.ts b/test/integration/webhook/invoicePaid/missingXeroInvoice.test.ts new file mode 100644 index 0000000..947e7f2 --- /dev/null +++ b/test/integration/webhook/invoicePaid/missingXeroInvoice.test.ts @@ -0,0 +1,56 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.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 { SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.paid missing Xero invoice', () => { + // The synced row has no xeroInvoiceId, so the service re-fetches the Copilot + // invoice and creates it in Xero before paying. + const apis = setupWebhookTest(() => ({ + copilot: createMockCopilotAPI({ + getInvoice: vi.fn().mockResolvedValue(buildInvoiceCreatedWebhook().data), + }), + })) + + it('creates the missing Xero invoice, then marks it paid', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'pending', xeroInvoiceId: null }) + + const res = await postWebhook(buildPaidInvoiceWebhook()) + expect(res.status).toBe(200) + + // Missing invoice is created first, then paid. The sales-code passed to + // markInvoicePaid is asserted in happyPath; here we only assert the ordering. + expect(apis.copilot.getInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.createInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.markInvoicePaid).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', + }) + + // Payment recorded and a paid sync log written. + expect(await db.select().from(syncedPayments)).toHaveLength(1) + const paidLogs = await db + .select() + .from(syncLogs) + .where( + and(eq(syncLogs.eventType, SyncEventType.PAID), eq(syncLogs.status, SyncStatus.SUCCESS)), + ) + expect(paidLogs).toHaveLength(1) + }) +}) From 69fd0a76448324f0b5426e2ab102e6dc2e253e78 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 8 Jul 2026 18:18:04 +0545 Subject: [PATCH 6/7] test(OUT-3951): assert invoice.paid returns 404 when the xero invoice is missing When getInvoiceById returns undefined the flow throws NOT_FOUND before any payment work: no payment row, no paid sync log, but a failed_syncs row is recorded and 404 is surfaced. Co-Authored-By: Claude Opus 4.8 --- .../invoicePaid/invoiceNotFound.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 test/integration/webhook/invoicePaid/invoiceNotFound.test.ts diff --git a/test/integration/webhook/invoicePaid/invoiceNotFound.test.ts b/test/integration/webhook/invoicePaid/invoiceNotFound.test.ts new file mode 100644 index 0000000..fb7bf17 --- /dev/null +++ b/test/integration/webhook/invoicePaid/invoiceNotFound.test.ts @@ -0,0 +1,54 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.webhook' +import { TEST_INVOICE } 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 { eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEventType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.paid Xero invoice not found', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + getInvoiceById: vi.fn().mockResolvedValue(undefined), + }), + })) + + it('records failed_syncs and returns 404 when the Xero invoice is missing', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildPaidInvoiceWebhook()) + expect(res.status).toBe(404) + + // Not-found short-circuits before any payment work. + expect(apis.xero.markInvoicePaid).not.toHaveBeenCalled() + expect(await db.select().from(syncedPayments)).toHaveLength(0) + + // The invoice row is left unchanged. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'success' }) + + // The NOT_FOUND error carries no failedSyncLogPayload, so no sync log... + const paidLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.PAID)) + expect(paidLogs).toHaveLength(0) + + // ...but a failed_syncs row is still recorded for retry. + const failed = await db.select().from(failedSyncs) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: ValidWebhookEvent.InvoicePaid, + resourceId: TEST_INVOICE.id, + }) + }) +}) From 931cb596f727b015ebb4e461865fe7bc955e9ae6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 8 Jul 2026 20:42:49 +0545 Subject: [PATCH 7/7] test(OUT-3951): assert failed paid log carries invoice.created metadata Seed a prior invoice.created success log and assert the failed paid log propagates invoiceNumber, customerName, and customerEmail via the failedSyncLogPayload spread, so a regression dropping those fields is caught. Matches the real scenario where a payment failure follows a successful create. Co-Authored-By: Claude Opus 4.8 --- .../invoicePaid/xeroMarkPaidFails.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts b/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts index 948c6bb..a607392 100644 --- a/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts +++ b/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts @@ -1,8 +1,8 @@ import { ValidWebhookEvent } from '@invoice-sync/types' import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.webhook' -import { TEST_INVOICE } from '@test/helpers/constants' +import { TEST_CLIENT, TEST_INVOICE } from '@test/helpers/constants' import { createMockXeroAPI } from '@test/helpers/mocks' -import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncLog } from '@test/helpers/seed' import { postWebhook } from '@test/helpers/webhook' import { setupWebhookTest } from '@test/helpers/webhookTestSetup' import { eq } from 'drizzle-orm' @@ -24,6 +24,9 @@ describe('POST /api/webhook — invoice.paid xero failure', () => { it('records failure in sync_logs and failed_syncs, and returns 500', async () => { await seedConnectedPortal() await seedSyncedInvoice({ status: 'success' }) + // A payment failure normally follows a successful invoice.created, whose log + // supplies the invoice metadata the failed paid log carries forward. + await seedSyncLog() const res = await postWebhook(buildPaidInvoiceWebhook()) expect(res.status).toBe(500) @@ -38,13 +41,19 @@ describe('POST /api/webhook — invoice.paid xero failure', () => { expect(invoices).toHaveLength(1) expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'success' }) - // A failed paid sync log is written. + // A failed paid sync log is written, carrying the invoice.created metadata + // forward via the failedSyncLogPayload spread. const paidLogs = await db .select() .from(syncLogs) .where(eq(syncLogs.eventType, SyncEventType.PAID)) expect(paidLogs).toHaveLength(1) - expect(paidLogs[0].status).toBe(SyncStatus.FAILED) + expect(paidLogs[0]).toMatchObject({ + status: SyncStatus.FAILED, + invoiceNumber: TEST_INVOICE.number, + customerName: `${TEST_CLIENT.givenName} ${TEST_CLIENT.familyName}`, + customerEmail: TEST_CLIENT.email, + }) // A failed_syncs row is recorded for retry. const failed = await db.select().from(failedSyncs)