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) + }) +}) 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) + }) +}) 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, + }) + }) +}) 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) + }) +}) 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) + }) +}) diff --git a/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts b/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts new file mode 100644 index 0000000..a607392 --- /dev/null +++ b/test/integration/webhook/invoicePaid/xeroMarkPaidFails.test.ts @@ -0,0 +1,66 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.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 { 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' }) + // 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) + + 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, 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]).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) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: ValidWebhookEvent.InvoicePaid, + resourceId: TEST_INVOICE.id, + }) + }) +})