Skip to content
32 changes: 32 additions & 0 deletions test/fixtures/paymentSucceeded.webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { z } from 'zod'

import { PaymentStatus } from '@/app/api/core/types/invoice'
import { PaymentSucceededResponseSchema } from '@/type/dto/webhook.dto'
import {
TEST_COPILOT_INVOICE_ID,
TEST_COPILOT_PAYMENT_ID,
} from '@test/helpers/seed'

type Envelope = {
eventType: 'payment.succeeded'
object: 'payment'
}

type PaymentSucceededFixture = Envelope &
z.input<typeof PaymentSucceededResponseSchema>

const paymentSucceededPayload: PaymentSucceededFixture = {
eventType: 'payment.succeeded',
object: 'payment',
data: {
id: TEST_COPILOT_PAYMENT_ID,
invoiceId: TEST_COPILOT_INVOICE_ID,
status: PaymentStatus.SUCCEEDED,
paymentMethod: 'creditCard',
brand: 'visa',
feeAmount: { paidByPlatform: 2500, paidByClient: 0 },
createdAt: '2024-02-21T15:31:16.789Z',
},
}

export default paymentSucceededPayload
35 changes: 27 additions & 8 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
TEST_INCOME_ACCOUNT_REF,
TEST_INTERNAL_USER_ID,
TEST_PORTAL_ID,
TEST_COPILOT_INVOICE_ID,
TEST_INVOICE_NUMBER,
TEST_QB_PURCHASE_ID,
} from './seed'

// Restricts override keys to the actual method names of the underlying class
Expand Down Expand Up @@ -60,7 +63,11 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) {
type: 'recurring',
}),
getPayments: vi.fn().mockResolvedValue({ data: [] }),
getInvoice: vi.fn().mockResolvedValue(undefined),
// payment.succeeded needs a real invoice object to proceed past the getInvoice guard (OUT-3773)
getInvoice: vi.fn().mockResolvedValue({
id: TEST_COPILOT_INVOICE_ID,
number: TEST_INVOICE_NUMBER,
}),
Comment on lines +66 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The id field in the getInvoice default is hardcoded as 'inv-cop-0001' while every other seed value in this file uses the named constant. TEST_COPILOT_INVOICE_ID already equals that string, so if the constant changes the mock silently drifts.

Suggested change
// payment.succeeded needs a real invoice object to proceed past the getInvoice guard (OUT-3773)
getInvoice: vi.fn().mockResolvedValue({
id: 'inv-cop-0001',
number: TEST_INVOICE_NUMBER,
}),
// payment.succeeded needs a real invoice object to proceed past the getInvoice guard (OUT-3773)
getInvoice: vi.fn().mockResolvedValue({
id: TEST_COPILOT_INVOICE_ID,
number: TEST_INVOICE_NUMBER,
}),

...overrides,
}
}
Expand All @@ -76,13 +83,19 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) {
export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) {
return {
getAnItem: vi.fn().mockResolvedValue(undefined),
getAnAccount: vi.fn().mockResolvedValue({
Id: TEST_INCOME_ACCOUNT_REF,
Name: 'Sales of Product Income',
SyncToken: '0',
Active: true,
AccountType: 'Income',
}),
// Echo the id back so callers (checkAndUpdateAccountStatus) get the ref
// they asked for; name-only queries fall back to the income account.
// Name and AccountType are stubs — they don't vary by id because no
// current caller inspects them. Parameterize if a future test does.
getAnAccount: vi
.fn()
.mockImplementation(async (_name?: string, id?: string) => ({
Id: id ?? TEST_INCOME_ACCOUNT_REF,
Name: 'Sales of Product Income',
SyncToken: '0',
Active: true,
AccountType: 'Income',
})),
Comment on lines +90 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 AccountType is always 'Income' for every account — the id-echo mock returns the same type regardless of which account id is passed. checkAndUpdateAccountStatus doesn't validate AccountType today so tests pass, but a future call site or test inspecting this field for an asset or expense account will silently receive the wrong type. Consider accepting AccountType as a parameter or adding a comment noting the field is a stub.

createItem: vi.fn().mockResolvedValue({
Id: '999',
Name: 'Test Product',
Expand Down Expand Up @@ -115,6 +128,12 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) {
createPayment: vi.fn().mockResolvedValue({
Payment: { Id: 'qb-pay-1', SyncToken: '0' },
}),
createPurchase: vi.fn().mockResolvedValue({
Purchase: { Id: TEST_QB_PURCHASE_ID, SyncToken: '0' },
}),
deletePurchase: vi.fn().mockResolvedValue({
Purchase: { Id: TEST_QB_PURCHASE_ID, status: 'Deleted' },
}),
// webhookInvoiceCreated pre-flights QBO for DocNumber collisions before
// every createInvoice call (OUT-3710). Default to "no collisions" so the
// base Assembly invoice number is used; override per-test to exercise the
Expand Down
44 changes: 44 additions & 0 deletions test/helpers/paymentSucceededTestSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { beforeEach, afterEach, vi } from 'vitest'
import { truncateAllTestTables } from '@test/helpers/testDb'
import {
installMockApis,
type MockCopilotAPI,
type MockIntuitAPI,
} from '@test/helpers/mocks'

type InstallOpts = Parameters<typeof installMockApis>[0]

export interface PaymentSucceededTestHandle {
copilot: MockCopilotAPI
intuit: MockIntuitAPI
}

/**
* Registers the standard `beforeEach` (truncate + installMockApis) and
* `afterEach` (clearAllMocks) hooks used by every payment.succeeded
* integration test. Mirrors `setupInvoiceCreatedTest` /
* `setupPriceCreatedTest`. The `optsFactory` is invoked once per test so
* callers can supply overrides whose underlying `vi.fn()`s are freshly
* instantiated.
*/
export function setupPaymentSucceededTest(
optsFactory?: () => InstallOpts,
): PaymentSucceededTestHandle {
const handle = {} as PaymentSucceededTestHandle

beforeEach(async () => {
await truncateAllTestTables()
const { copilot, intuit } = installMockApis(optsFactory?.())
handle.copilot = copilot
handle.intuit = intuit
})

afterEach(() => {
// clearAllMocks (not restoreAllMocks) — the module-level mock factories in
// test/integration/setup.ts must stay installed across tests; we only want
// to reset call counts and implementations set in beforeEach.
vi.clearAllMocks()
})

return handle
}
2 changes: 2 additions & 0 deletions test/helpers/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export const TEST_QB_CUSTOMER_ID = 'qb-cust-1'
export const TEST_QB_INVOICE_ID = 'qb-inv-1'
export const TEST_INVOICE_NUMBER = 'INV-0001'
export const TEST_COPILOT_INVOICE_ID = 'inv-cop-0001'
export const TEST_COPILOT_PAYMENT_ID = 'pay-cop-0001'
export const TEST_QB_PURCHASE_ID = 'qb-purch-1'

type CustomerOverrides = Partial<InferInsertModel<typeof QBCustomers>>
type InvoiceSyncOverrides = Partial<InferInsertModel<typeof QBInvoiceSync>>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'

import { db } from '@/db'
import { QBSyncLog } from '@/db/schema/qbSyncLogs'

import paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook'
import { seedHealthyPortal, seedQBInvoiceSync } from '@test/helpers/seed'
import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup'
import { postWebhook } from '@test/helpers/webhook'

describe('POST /api/quickbooks/webhook — payment.succeeded (absorbed-fee flag off)', () => {
const apis = setupPaymentSucceededTest()

it('skips processing when the portal opts out of recording absorbed fees', async () => {
await seedHealthyPortal({ setting: { absorbedFeeFlag: false } })
await seedQBInvoiceSync()

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

expect(await db.select().from(QBSyncLog)).toHaveLength(0)
expect(apis.copilot.getInvoice).not.toHaveBeenCalled()
expect(apis.intuit.getAnAccount).not.toHaveBeenCalled()
expect(apis.intuit.createPurchase).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest'
import { eq } from 'drizzle-orm'

import { db } from '@/db'
import { QBSyncLog } from '@/db/schema/qbSyncLogs'
import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log'

import paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook'
import {
seedHealthyPortal,
seedQBInvoiceSync,
TEST_COPILOT_PAYMENT_ID,
} from '@test/helpers/seed'
import { createMockCopilotAPI } from '@test/helpers/mocks'
import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup'
import { postWebhook } from '@test/helpers/webhook'

describe('POST /api/quickbooks/webhook — payment.succeeded (Copilot returns no invoice)', () => {
const apis = setupPaymentSucceededTest(() => ({
copilot: createMockCopilotAPI({
getInvoice: vi.fn().mockResolvedValue(undefined),
}),
}))

it('returns a 404 and does not call QuickBooks when Copilot cannot find the invoice', async () => {
await seedHealthyPortal({ setting: { absorbedFeeFlag: true } })
await seedQBInvoiceSync()

const res = await postWebhook(paymentSucceededPayload)
// The not-found throw escapes the inner try/catch (which only wraps the QB
// calls) and propagates to withErrorHandler, which surfaces it as 404.
expect(res.status).toBe(404)

// No FAILED log is written — the throw happens before the outer catch block
// that writes sync logs for QB-layer errors. The claimed PENDING row is the
// only row in the table.
const logs = await db
.select()
.from(QBSyncLog)
.where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID))
expect(logs).toHaveLength(1)
expect(logs[0]).toMatchObject({
entityType: EntityType.PAYMENT,
eventType: EventType.SUCCEEDED,
status: LogStatus.PENDING,
})

expect(apis.intuit.createPurchase).not.toHaveBeenCalled()
expect(apis.intuit.deletePurchase).not.toHaveBeenCalled()
})
})
84 changes: 84 additions & 0 deletions test/integration/quickbooks/paymentSucceeded/happyPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest'
import { eq } from 'drizzle-orm'

import { db } from '@/db'
import { QBSyncLog } from '@/db/schema/qbSyncLogs'
import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log'

import paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook'
import {
seedHealthyPortal,
seedQBInvoiceSync,
TEST_PORTAL_ID,
TEST_INVOICE_NUMBER,
TEST_COPILOT_INVOICE_ID,
TEST_COPILOT_PAYMENT_ID,
TEST_QB_PURCHASE_ID,
TEST_ASSET_ACCOUNT_REF,
TEST_EXPENSE_ACCOUNT_REF,
} from '@test/helpers/seed'
import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup'
import { postWebhook } from '@test/helpers/webhook'

describe('POST /api/quickbooks/webhook — payment.succeeded (absorbed-fee expense recorded)', () => {
const apis = setupPaymentSucceededTest()

it('records the absorbed fee as a QuickBooks expense and logs the sync as successful', async () => {
await seedHealthyPortal({ setting: { absorbedFeeFlag: true } })
await seedQBInvoiceSync()

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

// For (payment, succeeded) the polymorphic `quickbooks_id` column holds the
// QBO Purchase id, not a payment id. See memory/project_qb_sync_logs_semantics.md.
const logs = await db
.select()
.from(QBSyncLog)
.where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID))
expect(logs).toHaveLength(1)
expect(logs[0]).toMatchObject({
portalId: TEST_PORTAL_ID,
entityType: EntityType.PAYMENT,
eventType: EventType.SUCCEEDED,
status: LogStatus.SUCCESS,
copilotId: TEST_COPILOT_PAYMENT_ID,
quickbooksId: TEST_QB_PURCHASE_ID,
feeAmount: '2500.00',
qbItemName: 'Assembly Fees',
remark: 'Absorbed fees',
})

expect(apis.copilot.getInvoice).toHaveBeenCalledWith(
TEST_COPILOT_INVOICE_ID,
)
expect(apis.intuit.getAnAccount).toHaveBeenCalledTimes(2) // asset + expense
expect(apis.intuit.getAnAccount).toHaveBeenCalledWith(
undefined, // account name
TEST_ASSET_ACCOUNT_REF, // account id
true, // includeInactive
)
expect(apis.intuit.getAnAccount).toHaveBeenCalledWith(
undefined,
TEST_EXPENSE_ACCOUNT_REF,
true,
)
expect(apis.intuit.createPurchase).toHaveBeenCalledTimes(1)
expect(apis.intuit.deletePurchase).not.toHaveBeenCalled()

const [purchasePayload] = apis.intuit.createPurchase.mock.calls[0]
expect(purchasePayload).toMatchObject({
PaymentType: 'Cash',
AccountRef: { value: TEST_ASSET_ACCOUNT_REF },
DocNumber: TEST_INVOICE_NUMBER,
TxnDate: '2024-02-21',
})
expect(purchasePayload.Line[0]).toMatchObject({
DetailType: 'AccountBasedExpenseLineDetail',
Amount: 25,
AccountBasedExpenseLineDetail: {
AccountRef: { value: TEST_EXPENSE_ACCOUNT_REF },
},
})
})
})
54 changes: 54 additions & 0 deletions test/integration/quickbooks/paymentSucceeded/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'

import { db } from '@/db'
import { QBSyncLog } from '@/db/schema/qbSyncLogs'
import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log'

import paymentSucceededPayload from '@test/fixtures/paymentSucceeded.webhook'
import {
seedHealthyPortal,
seedQBInvoiceSync,
TEST_PORTAL_ID,
TEST_INVOICE_NUMBER,
TEST_COPILOT_PAYMENT_ID,
TEST_QB_PURCHASE_ID,
} from '@test/helpers/seed'
import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup'
import { postWebhook } from '@test/helpers/webhook'

describe('POST /api/quickbooks/webhook — payment.succeeded (same webhook delivered twice)', () => {
const apis = setupPaymentSucceededTest()

it('processes the payment only once when a sync log for it already exists', async () => {
await seedHealthyPortal({ setting: { absorbedFeeFlag: true } })
await seedQBInvoiceSync()

// Simulate a prior successful delivery that already claimed and processed
// this payment. Inline insert mirrors the pattern in invoiceCreated/idempotency.test.ts.
await db.insert(QBSyncLog).values({
portalId: TEST_PORTAL_ID,
entityType: EntityType.PAYMENT,
eventType: EventType.SUCCEEDED,
status: LogStatus.SUCCESS,
copilotId: TEST_COPILOT_PAYMENT_ID,
invoiceNumber: TEST_INVOICE_NUMBER,
quickbooksId: TEST_QB_PURCHASE_ID,
feeAmount: '2500.00',
qbItemName: 'Assembly Fees',
remark: 'Absorbed fees',
})

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

// Seeded log row stays exactly as it was; the second delivery did not
// overwrite it or insert a new row.
const logs = await db.select().from(QBSyncLog)
expect(logs).toHaveLength(1)
expect(logs[0].status).toBe(LogStatus.SUCCESS)

expect(apis.copilot.getInvoice).not.toHaveBeenCalled()
expect(apis.intuit.createPurchase).not.toHaveBeenCalled()
expect(apis.intuit.deletePurchase).not.toHaveBeenCalled()
})
})
Loading
Loading