Skip to content
Merged
2 changes: 2 additions & 0 deletions src/features/invoice-sync/lib/SyncedInvoices.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -366,6 +367,7 @@ class SyncedInvoicesService extends AuthenticatedXeroService {
error,
failedSyncLogPayload: {
...prevSyncLog,
entityType: SyncEntityType.INVOICE,
eventType: SyncEventType.VOIDED,
},
})
Expand Down
20 changes: 20 additions & 0 deletions test/fixtures/voidedInvoice.webhook.ts
Original file line number Diff line number Diff line change
@@ -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<typeof InvoiceVoidedWebhookSchema>
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<VoidedInvoiceData> = {},
): VoidedInvoiceWebhookInput {
return {
eventType: ValidWebhookEvent.InvoiceVoided,
data: {
id: TEST_INVOICE.id,
...dataOverrides,
},
}
}
5 changes: 5 additions & 0 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
52 changes: 52 additions & 0 deletions test/integration/webhook/invoiceVoided/happyPath.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
53 changes: 53 additions & 0 deletions test/integration/webhook/invoiceVoided/invoiceNotFound.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
57 changes: 57 additions & 0 deletions test/integration/webhook/invoiceVoided/missingXeroInvoice.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
39 changes: 39 additions & 0 deletions test/integration/webhook/invoiceVoided/noPriorCreatedLog.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
29 changes: 29 additions & 0 deletions test/integration/webhook/invoiceVoided/syncDisabled.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
64 changes: 64 additions & 0 deletions test/integration/webhook/invoiceVoided/xeroVoidFails.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
Loading