Skip to content

Commit 11f3442

Browse files
test(OUT-4011): cover mixed-payout invoice numbers and stale-object void
- mixed payout persists affected invoice numbers to remark; resolvePayments returns them - notifier surfaces the invoice list through the real copy (both channels) - invoice.voided on an OPEN row surfaces QBO 5010 as error_code (QB_STALE_OBJECT) - fix duplicate createDeposit mock key; add IU-notify Copilot mocks Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cfbe509 commit 11f3442

6 files changed

Lines changed: 172 additions & 3 deletions

File tree

test/helpers/mocks.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) {
7171
id: TEST_COPILOT_INVOICE_ID,
7272
number: TEST_INVOICE_NUMBER,
7373
}),
74+
// Deferred SyncErrorNotifier dispatch calls these; empty IU list = no-op.
75+
getInternalUsers: vi.fn().mockResolvedValue({ data: [] }),
76+
createNotification: vi.fn().mockResolvedValue({ id: 'notif-1' }),
7477
...overrides,
7578
}
7679
}
@@ -154,9 +157,6 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) {
154157
createPurchase: vi.fn().mockResolvedValue({
155158
Purchase: { Id: TEST_QB_PURCHASE_ID, SyncToken: '0' },
156159
}),
157-
createDeposit: vi.fn().mockResolvedValue({
158-
Deposit: { Id: 'qb-deposit-1', SyncToken: '0' },
159-
}),
160160
deletePurchase: vi.fn().mockResolvedValue({
161161
Purchase: { Id: TEST_QB_PURCHASE_ID, status: 'Deleted' },
162162
}),
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { and, eq } from 'drizzle-orm'
3+
4+
import { db } from '@/db'
5+
import { QBSyncLog } from '@/db/schema/qbSyncLogs'
6+
import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log'
7+
import { QBOErrorCodes } from '@/constant/intuitErrorCode'
8+
import { HttpFetchError } from '@/utils/error'
9+
10+
import { invoiceVoidedPayload } from '@test/fixtures/invoiceVoided.webhook'
11+
import {
12+
seedHealthyPortal,
13+
seedQBCustomer,
14+
seedQBInvoiceSync,
15+
seedInvoiceCreatedLog,
16+
TEST_COPILOT_INVOICE_ID,
17+
} from '@test/helpers/seed'
18+
import { createMockIntuitAPI } from '@test/helpers/mocks'
19+
import { setupInvoiceVoidedTest } from '@test/helpers/invoiceVoidedTestSetup'
20+
import { postWebhook } from '@test/helpers/webhook'
21+
22+
// Invoice voided out-of-band in QBO leaves our row OPEN, so the void hits a
23+
// stale SyncToken → 5010 → FAILED with error_code=5010 (routes to QB_STALE_OBJECT).
24+
describe('POST /api/quickbooks/webhook — invoice.voided (QBO returns 5010 stale object)', () => {
25+
const apis = setupInvoiceVoidedTest(() => ({
26+
intuit: createMockIntuitAPI({
27+
voidInvoice: vi.fn().mockRejectedValue(
28+
new HttpFetchError({
29+
status: 400,
30+
statusText: 'Bad Request',
31+
url: 'https://quickbooks.api.intuit.com/v3/company/realm/invoice',
32+
body: {
33+
Fault: {
34+
Error: [
35+
{
36+
code: String(QBOErrorCodes.STALE_OBJECT),
37+
Message: 'Stale Object Error',
38+
Detail:
39+
'Stale Object Error : You and quickbooks-sync were working on this at the same time.',
40+
},
41+
],
42+
type: 'ValidationFault',
43+
},
44+
},
45+
}),
46+
),
47+
}),
48+
}))
49+
50+
it('marks the voided log FAILED with error_code 5010 (routes to QB_STALE_OBJECT)', async () => {
51+
await seedHealthyPortal()
52+
const customer = await seedQBCustomer()
53+
await seedQBInvoiceSync({ customerId: customer.id }) // defaults to OPEN
54+
await seedInvoiceCreatedLog()
55+
56+
const res = await postWebhook(invoiceVoidedPayload)
57+
expect(res.status).toBe(200)
58+
59+
const [voidedLog] = await db
60+
.select()
61+
.from(QBSyncLog)
62+
.where(
63+
and(
64+
eq(QBSyncLog.copilotId, TEST_COPILOT_INVOICE_ID),
65+
eq(QBSyncLog.eventType, EventType.VOIDED),
66+
),
67+
)
68+
expect(voidedLog.entityType).toBe(EntityType.INVOICE)
69+
expect(voidedLog.status).toBe(LogStatus.FAILED)
70+
// The QBO fault code must survive as error_code to route to QB_STALE_OBJECT.
71+
expect(voidedLog.errorCode).toBe(String(QBOErrorCodes.STALE_OBJECT))
72+
73+
expect(apis.intuit.voidInvoice).toHaveBeenCalledTimes(1)
74+
})
75+
})

test/integration/quickbooks/payoutReconciliation/mixed.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { db } from '@/db'
55
import { QBSyncLog } from '@/db/schema/qbSyncLogs'
66
import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log'
77

8+
import { PAYOUT_MIXED_INTENT_CODE } from '@/constant/intuitErrorCode'
89
import { payoutPayload } from '@test/fixtures/payout.webhook'
910
import {
1011
seedHealthyPortal,
@@ -53,5 +54,13 @@ describe('payout — invoices froze a mix of batched and non-batched', () => {
5354
expect(payoutLog.status).toBe(LogStatus.FAILED)
5455
expect(payoutLog.shouldRetry).toBe(false)
5556
expect(payoutLog.errorMessage).toContain('mixes batched and non-batched')
57+
// Routable sentinel so SyncErrorNotifier notifies IUs for manual reconciliation.
58+
expect(payoutLog.errorCode).toBe(PAYOUT_MIXED_INTENT_CODE)
59+
// No qbItemName: it would outrank copilotId in the notification's entity
60+
// reference, hiding which payout to reconcile.
61+
expect(payoutLog.qbItemName).toBeNull()
62+
// remark carries the affected invoice numbers so the IU notification can
63+
// name which invoices went unrecorded.
64+
expect(payoutLog.remark).toBe('INV-A, INV-B')
5665
})
5766
})

test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ describe('SyncLogService.getSuccessfulPaidPaymentIds', () => {
5959
expect(result.get('inv_a')).toEqual({
6060
paymentId: 'qbpay_a',
6161
isBatchedDeposit: true,
62+
invoiceNumber: 'INV-A',
6263
})
6364
expect(result.has('inv_b')).toBe(false) // FAILED excluded
6465
expect(result.has('inv_c')).toBe(false) // other portal excluded

test/unit/notification/notification.helper.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,38 @@ describe('getInProductNotificationDetail', () => {
8282
expect(detail.body).not.toContain('payment completion')
8383
})
8484

85+
it('renders payout reconciliation with the payout id as ref and names the affected invoices', () => {
86+
const ctx: NotificationContext = {
87+
entityType: 'payout',
88+
eventType: 'settled',
89+
entityKey: 'po_test_1',
90+
invoiceNumbers: 'INV-A, INV-B',
91+
}
92+
const detail = getInProductNotificationDetail(
93+
NotificationActions.QB_PAYOUT_MIXED_INTENT,
94+
ctx,
95+
)
96+
expect(detail.body).toContain('during payout reconciliation, ref po_test_1')
97+
expect(detail.body).not.toContain('ref Stripe payout')
98+
expect(detail.body).toContain(
99+
'No deposit was created for invoices INV-A, INV-B',
100+
)
101+
})
102+
103+
it('omits the invoice list from the payout body when no invoice numbers are present', () => {
104+
const ctx: NotificationContext = {
105+
entityType: 'payout',
106+
eventType: 'settled',
107+
entityKey: 'po_test_1',
108+
}
109+
const detail = getInProductNotificationDetail(
110+
NotificationActions.QB_PAYOUT_MIXED_INTENT,
111+
ctx,
112+
)
113+
expect(detail.body).toContain('No deposit was created, so nothing')
114+
expect(detail.body).not.toContain('for invoices')
115+
})
116+
85117
it('5010 (invoice-only after suppression) warns that the failure is final', () => {
86118
const ctx: NotificationContext = {
87119
entityType: 'invoice',

test/unit/quickbooks/syncErrorNotifier.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,15 @@ import {
4242
getEntityKey,
4343
} from '@/app/api/quickbooks/syncLog/syncErrorNotifier'
4444
import {
45+
AppActionableErrorCodes,
46+
PAYOUT_MIXED_INTENT_CODE,
4547
QBOErrorCodes,
4648
UserActionableErrorCodes,
4749
} from '@/constant/intuitErrorCode'
50+
import {
51+
getIEmailNotificationDetail,
52+
getInProductNotificationDetail,
53+
} from '@/app/api/notification/notification.helper'
4854

4955
const baseLog: QBSyncLogSelectSchemaType = {
5056
id: 'log-1',
@@ -70,6 +76,7 @@ const baseLog: QBSyncLogSelectSchemaType = {
7076
errorCode: String(QBOErrorCodes.CLOSED_PERIOD),
7177
category: 'qb_api_error' as never,
7278
attempt: 0,
79+
shouldRetry: true,
7380
createdAt: new Date(),
7481
updatedAt: new Date(),
7582
deletedAt: null,
@@ -86,6 +93,14 @@ describe('getActionForErrorCode', () => {
8693
},
8794
)
8895

96+
// Self-extending over the app-level sentinel registry, mirroring the QBO one.
97+
it.each(Object.entries(AppActionableErrorCodes))(
98+
'maps app sentinel code %s to action %s',
99+
(code, expectedAction) => {
100+
expect(getActionForErrorCode(code)).toBe(expectedAction)
101+
},
102+
)
103+
89104
it('returns null for unknown / transient / auth codes', () => {
90105
expect(getActionForErrorCode('429')).toBeNull()
91106
expect(getActionForErrorCode('500')).toBeNull()
@@ -223,6 +238,43 @@ describe('SyncErrorNotifier#notify', () => {
223238
},
224239
)
225240

241+
it('dispatches the mixed-payout notification for a FAILED payout with the sentinel code', async () => {
242+
const notifier = new SyncErrorNotifier(user)
243+
244+
await notifier.notify({
245+
...baseLog,
246+
entityType: 'payout' as never,
247+
eventType: 'settled' as never,
248+
errorCode: PAYOUT_MIXED_INTENT_CODE,
249+
quickbooksId: null,
250+
invoiceNumber: null,
251+
copilotId: 'po_test_1',
252+
// Webhook stashes the affected invoice numbers in remark for this action.
253+
remark: 'INV-A, INV-B',
254+
errorMessage:
255+
'Payout po_test_1 mixes batched and non-batched invoices; unsupported',
256+
})
257+
258+
expect(sendNotificationToIU).toHaveBeenCalledTimes(1)
259+
const [, action, ctx] = sendNotificationToIU.mock.calls[0]
260+
expect(action).toBe(NotificationActions.QB_PAYOUT_MIXED_INTENT)
261+
// copilotId stays the ref; the invoice list rides in invoiceNumbers.
262+
expect(ctx).toMatchObject({
263+
entityType: 'payout',
264+
entityKey: 'po_test_1',
265+
invoiceNumbers: 'INV-A, INV-B',
266+
})
267+
268+
// Close the seam: the ctx extracted from `remark` must render the invoice
269+
// list in the real copy (both channels), with the payout id as the ref.
270+
const inProduct = getInProductNotificationDetail(action, ctx)
271+
const email = getIEmailNotificationDetail(action, ctx)
272+
for (const body of [inProduct.body, email.body]) {
273+
expect(body).toContain('ref po_test_1')
274+
expect(body).toContain('No deposit was created for invoices INV-A, INV-B')
275+
}
276+
})
277+
226278
it('dispatches a notification for a FAILED row with a user-actionable code', async () => {
227279
const notifier = new SyncErrorNotifier(user)
228280

0 commit comments

Comments
 (0)