Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions test/fixtures/paymentSucceeded.webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
PaymentStatus,
type PaymentSucceededWebhookSchema,
ValidWebhookEvent,
} from '@invoice-sync/types'
import { TEST_FEE, TEST_INVOICE, TEST_PAYMENT } from '@test/helpers/constants'
import type { z } from 'zod'

type PaymentSucceededWebhookInput = z.input<typeof PaymentSucceededWebhookSchema>
type PaymentSucceededData = PaymentSucceededWebhookInput['data']

// Builds a payment.succeeded payload. Fee is TEST_FEE (cents). Pass `dataOverrides` to vary.
export function buildPaymentSucceededWebhook(
dataOverrides: Partial<PaymentSucceededData> = {},
): PaymentSucceededWebhookInput {
return {
eventType: ValidWebhookEvent.PaymentSucceeded,
data: {
id: TEST_PAYMENT.id,
invoiceId: TEST_INVOICE.id,
status: PaymentStatus.SUCCEEDED,
paymentMethod: 'card',
brand: 'visa',
feeAmount: { paidByPlatform: TEST_FEE.cents, paidByClient: 0 },
createdAt: '2026-01-01T00:00:00.000Z',
...dataOverrides,
},
}
}
17 changes: 17 additions & 0 deletions test/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,20 @@ export const TEST_XERO_INVOICE = { id: '88888888-8888-4888-8888-888888888888', t
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' }

// payment.succeeded: the Copilot payment id and the Xero SPEND bank-txn id (v4 uuid).
export const TEST_PAYMENT = { id: 'test-payment-00000001' }
export const TEST_XERO_BANK_TXN = { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' }

// Absorbed platform fee. `cents` is the single source; a non-round value catches
// a /100 bug. dollars/dollarsString derive it for the line item and sync-log columns.
const feeInCents = 237
export const TEST_FEE = {
cents: feeInCents,
dollars: feeInCents / 100,
dollarsString: String(feeInCents / 100),
}

// Absorbed-fee asset + expense accounts. Only id is used; region codes asserted inline.
export const TEST_ASSET_ACCOUNT = { id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' }
export const TEST_EXPENSE_ACCOUNT = { id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }
30 changes: 30 additions & 0 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import {
TEST_ASSET_ACCOUNT,
TEST_CLIENT,
TEST_COMPANY,
TEST_EXPENSE_ACCOUNT,
TEST_INVOICE,
TEST_PORTAL,
TEST_SALES_ACCOUNT,
TEST_XERO_BANK_TXN,
TEST_XERO_CONTACT,
TEST_XERO_INVOICE,
TEST_XERO_ITEM,
Expand Down Expand Up @@ -132,6 +135,33 @@ export function createMockXeroAPI(overrides: XeroAPIOverrides = {}) {
invoiceID: TEST_XERO_INVOICE.id,
status: 'DELETED',
}),
// payment.succeeded: getAccounts=[] so accounts are created; no existing expense.
createFixedAssetsAccount: vi.fn(
async (_tenantId: string, account: { code: string; name: string }) => ({
accountID: TEST_ASSET_ACCOUNT.id,
code: account.code,
name: account.name,
type: 'BANK',
status: 'ACTIVE',
}),
),
createExpenseAccount: vi.fn(
async (_tenantId: string, account: { code: string; name: string }) => ({
accountID: TEST_EXPENSE_ACCOUNT.id,
code: account.code,
name: account.name,
type: 'EXPENSE',
status: 'ACTIVE',
enablePaymentsToAccount: true,
}),
),
// No existing expense by reference or legacy invoice-id match.
findBankTransactionByReference: vi.fn().mockResolvedValue(undefined),
findLegacyExpenseByInvoice: vi.fn().mockResolvedValue(undefined),
createBankTransaction: vi.fn().mockResolvedValue({
bankTransactionID: TEST_XERO_BANK_TXN.id,
status: 'AUTHORISED',
}),
...overrides,
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.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 { syncedPayments } from '@/db/schema/syncedPayments.schema'
import { SyncEntityType, syncLogs } from '@/db/schema/syncLogs.schema'

describe('POST /api/webhook — payment.succeeded absorbed fees disabled', () => {
const apis = setupWebhookTest()

it('skips expense creation when addAbsorbedFees is false', async () => {
// addAbsorbedFees defaults to false in the seed; sync is otherwise enabled.
await seedConnectedPortal()
await seedSyncedInvoice({ status: 'success' })

const res = await postWebhook(buildPaymentSucceededWebhook())
expect(res.status).toBe(200)

expect(apis.xero.createBankTransaction).not.toHaveBeenCalled()
expect(await db.select().from(syncedPayments)).toHaveLength(0)
const expenseLogs = await db
.select()
.from(syncLogs)
.where(eq(syncLogs.entityType, SyncEntityType.EXPENSE))
expect(expenseLogs).toHaveLength(0)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook'
import { TEST_XERO_BANK_TXN } from '@test/helpers/constants'
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 { SyncEntityType, syncLogs } from '@/db/schema/syncLogs.schema'

describe('POST /api/webhook — payment.succeeded concurrent duplicate', () => {
const apis = setupWebhookTest()

it('returns the transaction but skips the sync log when the insert no-ops', async () => {
await seedConnectedPortal({ settings: { addAbsorbedFees: true } })
await seedSyncedInvoice({ status: 'success' })
// Seed a row with the same xeroPaymentId. It dodges the EXPENSE replay guard
// but collides on the unique index, so the insert no-ops — the race-guard branch.
await seedSyncedPayment({ xeroPaymentId: TEST_XERO_BANK_TXN.id })

const res = await postWebhook(buildPaymentSucceededWebhook())
expect(res.status).toBe(200)

// A create was attempted (guard missed), but the insert conflicted.
expect(apis.xero.createBankTransaction).toHaveBeenCalledTimes(1)

// Still just the seeded row; no expense sync log because the insert no-op'd.
expect(await db.select().from(syncedPayments)).toHaveLength(1)
const expenseLogs = await db
.select()
.from(syncLogs)
.where(eq(syncLogs.entityType, SyncEntityType.EXPENSE))
expect(expenseLogs).toHaveLength(0)
})
})
77 changes: 77 additions & 0 deletions test/integration/webhook/paymentSucceeded/expenseReuse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook'
import { TEST_FEE, TEST_PAYMENT, TEST_XERO_BANK_TXN } 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, type WebhookTestHandle } from '@test/helpers/webhookTestSetup'
import { and, eq } from 'drizzle-orm'
import { describe, expect, it, vi } from 'vitest'
import db from '@/db'
import { syncedPayments } from '@/db/schema/syncedPayments.schema'
import { SyncEntityType, SyncEventType, syncLogs } from '@/db/schema/syncLogs.schema'

const foundExpense = { bankTransactionID: TEST_XERO_BANK_TXN.id, status: 'AUTHORISED' }

// The two reuse arms of the resolution chain: findBankTransactionByReference ??
// findLegacyExpenseByInvoice ?? createBankTransaction. A hit on either reuses the
// found expense instead of creating a new transaction; only the lookup differs.
const CASES = [
{
label: 'by reference',
override: () => ({ findBankTransactionByReference: vi.fn().mockResolvedValue(foundExpense) }),
verifyLookups: (apis: WebhookTestHandle) => {
expect(apis.xero.findBankTransactionByReference).toHaveBeenCalledTimes(1)
// Reference hit short-circuits before the legacy lookup.
expect(apis.xero.findLegacyExpenseByInvoice).not.toHaveBeenCalled()
},
},
{
label: 'legacy by invoice',
override: () => ({
findLegacyExpenseByInvoice: vi
.fn()
.mockResolvedValue({ ...foundExpense, total: TEST_FEE.dollars }),
}),
verifyLookups: (apis: WebhookTestHandle) => {
// Reference lookup ran first and missed, so the chain fell through to legacy.
expect(apis.xero.findBankTransactionByReference).toHaveBeenCalledTimes(1)
expect(apis.xero.findLegacyExpenseByInvoice).toHaveBeenCalledTimes(1)
},
},
] as const

describe.each(CASES)(
'POST /api/webhook — payment.succeeded reuses an expense ($label)',
(testCase) => {
const apis = setupWebhookTest(() => ({ xero: createMockXeroAPI(testCase.override()) }))

it('records the found transaction and skips createBankTransaction', async () => {
await seedConnectedPortal({ settings: { addAbsorbedFees: true } })
await seedSyncedInvoice({ status: 'success' })

const res = await postWebhook(buildPaymentSucceededWebhook())
expect(res.status).toBe(200)

testCase.verifyLookups(apis)
expect(apis.xero.createBankTransaction).not.toHaveBeenCalled()

const payments = await db.select().from(syncedPayments)
expect(payments).toHaveLength(1)
expect(payments[0]).toMatchObject({
xeroPaymentId: TEST_XERO_BANK_TXN.id,
copilotPaymentId: TEST_PAYMENT.id,
type: 'expense',
})
const expenseLogs = await db
.select()
.from(syncLogs)
.where(
and(
eq(syncLogs.entityType, SyncEntityType.EXPENSE),
eq(syncLogs.eventType, SyncEventType.CREATED),
),
)
expect(expenseLogs).toHaveLength(1)
})
},
)
86 changes: 86 additions & 0 deletions test/integration/webhook/paymentSucceeded/happyPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook'
import {
TEST_FEE,
TEST_INVOICE,
TEST_PAYMENT,
TEST_PORTAL,
TEST_XERO_BANK_TXN,
TEST_XERO_INVOICE,
} from '@test/helpers/constants'
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 } from 'vitest'
import db from '@/db'
import { failedSyncs } from '@/db/schema/failedSyncs.schema'
import { syncedPayments } from '@/db/schema/syncedPayments.schema'
import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema'

// Absorbed-fee account codes; differ US vs AU.
const REGIONS = [
{ countryCode: 'US', bankCode: '2001', feesCode: '6041' },
{ countryCode: 'AU', bankCode: '9010', feesCode: '9020' },
] as const

describe.each(REGIONS)('POST /api/webhook — payment.succeeded [$countryCode]', (region) => {
const apis = setupWebhookTest()

it('creates a SPEND expense, records synced_payments, and logs the expense', async () => {
await seedConnectedPortal({
settings: { countryCode: region.countryCode, addAbsorbedFees: true },
})
await seedSyncedInvoice({ status: 'success' })

const res = await postWebhook(buildPaymentSucceededWebhook())
expect(res.status).toBe(200)

// One SPEND transaction referencing the payment id, fee from TEST_FEE, region codes.
expect(apis.xero.createBankTransaction).toHaveBeenCalledTimes(1)
const [tenantId, payload, idempotencyKey] = apis.xero.createBankTransaction.mock.calls[0]
expect(tenantId).toBe(TEST_PORTAL.tenantId)
expect(idempotencyKey).toBe(TEST_PAYMENT.id)
expect(payload).toMatchObject({
type: 'SPEND',
reference: TEST_PAYMENT.id,
bankAccount: { code: region.bankCode },
contact: { name: 'Assembly Processing Fees' },
lineItems: [{ accountCode: region.feesCode, quantity: 1, unitAmount: TEST_FEE.dollars }],
})

// An EXPENSE synced_payments row keyed by the Copilot payment id.
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_BANK_TXN.id,
copilotPaymentId: TEST_PAYMENT.id,
type: 'expense',
})

// An expense created-success sync log carrying the fee amount.
const expenseLogs = await db
.select()
.from(syncLogs)
.where(
and(
eq(syncLogs.entityType, SyncEntityType.EXPENSE),
eq(syncLogs.eventType, SyncEventType.CREATED),
),
)
expect(expenseLogs).toHaveLength(1)
expect(expenseLogs[0]).toMatchObject({
status: SyncStatus.SUCCESS,
copilotId: TEST_INVOICE.id,
xeroId: TEST_XERO_BANK_TXN.id,
amount: TEST_FEE.dollarsString,
feeAmount: TEST_FEE.dollarsString,
})

// No failure recorded on the happy path.
expect(await db.select().from(failedSyncs)).toHaveLength(0)
})
})
38 changes: 38 additions & 0 deletions test/integration/webhook/paymentSucceeded/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { buildPaymentSucceededWebhook } from '@test/fixtures/paymentSucceeded.webhook'
import { TEST_PAYMENT } from '@test/helpers/constants'
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 { PaymentUserType, syncedPayments } from '@/db/schema/syncedPayments.schema'
import { SyncEntityType, syncLogs } from '@/db/schema/syncLogs.schema'

describe('POST /api/webhook — payment.succeeded idempotency', () => {
const apis = setupWebhookTest()

it('skips creating an expense when one already exists for the payment', async () => {
await seedConnectedPortal({ settings: { addAbsorbedFees: true } })
await seedSyncedInvoice({ status: 'success' })
// An EXPENSE row already recorded for this Copilot payment id.
await seedSyncedPayment({
type: PaymentUserType.EXPENSE,
copilotPaymentId: TEST_PAYMENT.id,
})

const res = await postWebhook(buildPaymentSucceededWebhook())
expect(res.status).toBe(200)

// Replay guard short-circuits before any Xero write.
expect(apis.xero.createBankTransaction).not.toHaveBeenCalled()

// Still exactly one payment row, and no expense sync log was added.
expect(await db.select().from(syncedPayments)).toHaveLength(1)
const expenseLogs = await db
.select()
.from(syncLogs)
.where(eq(syncLogs.entityType, SyncEntityType.EXPENSE))
expect(expenseLogs).toHaveLength(0)
})
})
Loading
Loading