diff --git a/src/features/invoice-sync/lib/SyncedInvoices.service.ts b/src/features/invoice-sync/lib/SyncedInvoices.service.ts index 2d6d44c..1dedab1 100644 --- a/src/features/invoice-sync/lib/SyncedInvoices.service.ts +++ b/src/features/invoice-sync/lib/SyncedInvoices.service.ts @@ -355,6 +355,7 @@ class SyncedInvoicesService extends AuthenticatedXeroService { // Add to sync log await syncLogsService.createSyncLog({ ...prevSyncLog, + entityType: SyncEntityType.INVOICE, eventType: SyncEventType.VOIDED, status: SyncStatus.SUCCESS, syncDate: new Date(), @@ -366,6 +367,7 @@ class SyncedInvoicesService extends AuthenticatedXeroService { error, failedSyncLogPayload: { ...prevSyncLog, + entityType: SyncEntityType.INVOICE, eventType: SyncEventType.VOIDED, }, }) diff --git a/test/fixtures/voidedInvoice.webhook.ts b/test/fixtures/voidedInvoice.webhook.ts new file mode 100644 index 0000000..d53f042 --- /dev/null +++ b/test/fixtures/voidedInvoice.webhook.ts @@ -0,0 +1,20 @@ +import { type InvoiceVoidedWebhookSchema, ValidWebhookEvent } from '@invoice-sync/types' +import { TEST_INVOICE } from '@test/helpers/constants' +import type { z } from 'zod' + +type VoidedInvoiceWebhookInput = z.input +type VoidedInvoiceData = VoidedInvoiceWebhookInput['data'] + +// Builds an invoice.voided webhook payload. The payload is just { id }; pass +// `dataOverrides` to vary the invoice id. +export function buildVoidedInvoiceWebhook( + dataOverrides: Partial = {}, +): VoidedInvoiceWebhookInput { + return { + eventType: ValidWebhookEvent.InvoiceVoided, + data: { + id: TEST_INVOICE.id, + ...dataOverrides, + }, + } +} diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 063aa3d..17c0ced 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -122,6 +122,11 @@ export function createMockXeroAPI(overrides: XeroAPIOverrides = {}) { total: TEST_XERO_INVOICE.total, }), markInvoicePaid: vi.fn().mockResolvedValue({ paymentID: TEST_XERO_PAYMENT.id }), + // invoice.voided: void the fetched Xero invoice. + voidInvoice: vi.fn().mockResolvedValue({ + invoiceID: TEST_XERO_INVOICE.id, + status: 'VOIDED', + }), ...overrides, } } diff --git a/test/integration/webhook/invoiceVoided/happyPath.test.ts b/test/integration/webhook/invoiceVoided/happyPath.test.ts new file mode 100644 index 0000000..104350a --- /dev/null +++ b/test/integration/webhook/invoiceVoided/happyPath.test.ts @@ -0,0 +1,52 @@ +import { buildVoidedInvoiceWebhook } from '@test/fixtures/voidedInvoice.webhook' +import { TEST_CLIENT, TEST_INVOICE, TEST_PORTAL, TEST_XERO_INVOICE } 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 { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.voided', () => { + const apis = setupWebhookTest() + + it('voids the Xero invoice and logs the voided event', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + await seedSyncLog() + + const res = await postWebhook(buildVoidedInvoiceWebhook()) + expect(res.status).toBe(200) + + // Xero invoice fetched, then voided against the tenant. + expect(apis.xero.getInvoiceById).toHaveBeenCalledTimes(1) + expect(apis.xero.voidInvoice).toHaveBeenCalledTimes(1) + const [tenantId, xeroInvoiceId] = apis.xero.voidInvoice.mock.calls[0] + expect(tenantId).toBe(TEST_PORTAL.tenantId) + expect(xeroInvoiceId).toBe(TEST_XERO_INVOICE.id) + + // Invoice row is untouched by the void. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'success' }) + + // A voided success sync log written, carrying the created log's invoice fields. + const voidedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.VOIDED)) + expect(voidedLogs).toHaveLength(1) + expect(voidedLogs[0]).toMatchObject({ + status: SyncStatus.SUCCESS, + invoiceNumber: TEST_INVOICE.number, + customerName: `${TEST_CLIENT.givenName} ${TEST_CLIENT.familyName}`, + entityType: SyncEntityType.INVOICE, + }) + + // No failure recorded on the happy path. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/invoiceVoided/invoiceNotFound.test.ts b/test/integration/webhook/invoiceVoided/invoiceNotFound.test.ts new file mode 100644 index 0000000..4ed6f30 --- /dev/null +++ b/test/integration/webhook/invoiceVoided/invoiceNotFound.test.ts @@ -0,0 +1,53 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildVoidedInvoiceWebhook } from '@test/fixtures/voidedInvoice.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 { SyncEventType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.voided 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(buildVoidedInvoiceWebhook()) + expect(res.status).toBe(404) + + expect(apis.xero.getInvoiceById).toHaveBeenCalledTimes(1) + // Not-found short-circuits before any void work. + expect(apis.xero.voidInvoice).not.toHaveBeenCalled() + + // 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 voidedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.VOIDED)) + expect(voidedLogs).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.InvoiceVoided, + resourceId: TEST_INVOICE.id, + }) + }) +}) diff --git a/test/integration/webhook/invoiceVoided/missingXeroInvoice.test.ts b/test/integration/webhook/invoiceVoided/missingXeroInvoice.test.ts new file mode 100644 index 0000000..bf6fc92 --- /dev/null +++ b/test/integration/webhook/invoiceVoided/missingXeroInvoice.test.ts @@ -0,0 +1,57 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { buildVoidedInvoiceWebhook } from '@test/fixtures/voidedInvoice.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 { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.voided missing Xero invoice', () => { + // The synced row has no xeroInvoiceId, so the service re-fetches the Copilot + // invoice and creates it in Xero before voiding. + const apis = setupWebhookTest(() => ({ + copilot: createMockCopilotAPI({ + getInvoice: vi.fn().mockResolvedValue(buildInvoiceCreatedWebhook().data), + }), + })) + + it('creates the missing Xero invoice, then voids it', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'pending', xeroInvoiceId: null }) + + const res = await postWebhook(buildVoidedInvoiceWebhook()) + expect(res.status).toBe(200) + + // Missing invoice is created first, then voided. + expect(apis.copilot.getInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.createInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.voidInvoice).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', + }) + + // A voided success sync log written. + const voidedLogs = await db + .select() + .from(syncLogs) + .where( + and( + eq(syncLogs.eventType, SyncEventType.VOIDED), + eq(syncLogs.status, SyncStatus.SUCCESS), + eq(syncLogs.entityType, SyncEntityType.INVOICE), + ), + ) + expect(voidedLogs).toHaveLength(1) + }) +}) diff --git a/test/integration/webhook/invoiceVoided/noPriorCreatedLog.test.ts b/test/integration/webhook/invoiceVoided/noPriorCreatedLog.test.ts new file mode 100644 index 0000000..992c531 --- /dev/null +++ b/test/integration/webhook/invoiceVoided/noPriorCreatedLog.test.ts @@ -0,0 +1,39 @@ +import { buildVoidedInvoiceWebhook } from '@test/fixtures/voidedInvoice.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 { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.voided without a prior created log', () => { + const apis = setupWebhookTest() + + it('voids successfully even when no invoice.created log exists', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + // No seedSyncLog(): the invoice.created log is absent, so the voided log + // must default its NOT-NULL entityType rather than inherit it. + + const res = await postWebhook(buildVoidedInvoiceWebhook()) + expect(res.status).toBe(200) + + expect(apis.xero.voidInvoice).toHaveBeenCalledTimes(1) + + // The voided success log still writes, defaulting entityType to invoice. + const voidedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.VOIDED)) + expect(voidedLogs).toHaveLength(1) + expect(voidedLogs[0]).toMatchObject({ + status: SyncStatus.SUCCESS, + entityType: SyncEntityType.INVOICE, + }) + + // The void succeeded, so nothing is recorded for retry. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/invoiceVoided/syncDisabled.test.ts b/test/integration/webhook/invoiceVoided/syncDisabled.test.ts new file mode 100644 index 0000000..1676ab4 --- /dev/null +++ b/test/integration/webhook/invoiceVoided/syncDisabled.test.ts @@ -0,0 +1,29 @@ +import { buildVoidedInvoiceWebhook } from '@test/fixtures/voidedInvoice.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 { SyncEventType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.voided 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(buildVoidedInvoiceWebhook()) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ message: 'Sync is disabled for this workspace' }) + + // Handler never runs: no void call and no voided log. + expect(apis.xero.voidInvoice).not.toHaveBeenCalled() + const voidedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.VOIDED)) + expect(voidedLogs).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/invoiceVoided/xeroVoidFails.test.ts b/test/integration/webhook/invoiceVoided/xeroVoidFails.test.ts new file mode 100644 index 0000000..789e84b --- /dev/null +++ b/test/integration/webhook/invoiceVoided/xeroVoidFails.test.ts @@ -0,0 +1,64 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildVoidedInvoiceWebhook } from '@test/fixtures/voidedInvoice.webhook' +import { TEST_CLIENT, TEST_INVOICE } from '@test/helpers/constants' +import { createMockXeroAPI } from '@test/helpers/mocks' +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, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.voided xero failure', () => { + // Override voidInvoice to throw; a fresh factory per test keeps it isolated. + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + voidInvoice: vi.fn().mockRejectedValue(new Error('Xero 500: void rejected')), + }), + })) + + it('records failure in sync_logs and failed_syncs, and returns 500', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + // A void failure normally follows a successful invoice.created, whose log + // supplies the invoice metadata the failed voided log carries forward. + await seedSyncLog() + + const res = await postWebhook(buildVoidedInvoiceWebhook()) + expect(res.status).toBe(500) + + expect(apis.xero.voidInvoice).toHaveBeenCalledTimes(1) + + // The invoice row is untouched by the failed void. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'success' }) + + // A failed voided sync log is written, carrying the invoice.created metadata + // forward via the failedSyncLogPayload spread. + const voidedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.VOIDED)) + expect(voidedLogs).toHaveLength(1) + expect(voidedLogs[0]).toMatchObject({ + status: SyncStatus.FAILED, + invoiceNumber: TEST_INVOICE.number, + customerName: `${TEST_CLIENT.givenName} ${TEST_CLIENT.familyName}`, + customerEmail: TEST_CLIENT.email, + errorMessage: 'Failed to void invoice', + entityType: SyncEntityType.INVOICE, + }) + + // 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.InvoiceVoided, + resourceId: TEST_INVOICE.id, + }) + }) +})