-
Notifications
You must be signed in to change notification settings - Fork 0
OUT-3773: integration tests for payment.succeeded webhook event #256
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
ddd4921
45eae49
6a2faf3
948c2fe
f00b91b
5be6a22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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, | ||
| }), | ||
| ...overrides, | ||
| } | ||
| } | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| createItem: vi.fn().mockResolvedValue({ | ||
| Id: '999', | ||
| Name: 'Test Product', | ||
|
|
@@ -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 | ||
|
|
||
| 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 | ||
| } |
| 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() | ||
| }) | ||
| }) |
| 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 }, | ||
| }, | ||
| }) | ||
| }) | ||
| }) |
| 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() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
idfield in thegetInvoicedefault is hardcoded as'inv-cop-0001'while every other seed value in this file uses the named constant.TEST_COPILOT_INVOICE_IDalready equals that string, so if the constant changes the mock silently drifts.