Skip to content
20 changes: 20 additions & 0 deletions test/fixtures/paidInvoice.webhook.ts
Original file line number Diff line number Diff line change
@@ -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<typeof InvoicePaidWebhookSchema>
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<PaidInvoiceData> = {},
): PaidInvoiceWebhookInput {
return {
eventType: ValidWebhookEvent.InvoicePaid,
data: {
id: TEST_INVOICE.id,
...dataOverrides,
},
}
}
6 changes: 5 additions & 1 deletion test/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
10 changes: 10 additions & 0 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
}
}
Expand Down
51 changes: 51 additions & 0 deletions test/helpers/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<InferInsertModel<typeof xeroConnections>>
Expand Down Expand Up @@ -134,3 +137,51 @@ export async function seedSyncedContact(overrides: SyncedContactOverrides = {})
.returning()
return row
}

type SyncedPaymentOverrides = Partial<InferInsertModel<typeof syncedPayments>>

const baseSyncedPayment: InferInsertModel<typeof syncedPayments> = {
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<InferInsertModel<typeof syncLogs>>

const baseSyncLog: InferInsertModel<typeof syncLogs> = {
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
}
81 changes: 81 additions & 0 deletions test/integration/webhook/invoicePaid/happyPath.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
34 changes: 34 additions & 0 deletions test/integration/webhook/invoicePaid/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
54 changes: 54 additions & 0 deletions test/integration/webhook/invoicePaid/invoiceNotFound.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
56 changes: 56 additions & 0 deletions test/integration/webhook/invoicePaid/missingXeroInvoice.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
24 changes: 24 additions & 0 deletions test/integration/webhook/invoicePaid/syncDisabled.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading