diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index fd69504f..91d71718 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -550,7 +550,7 @@ export class InvoiceService extends BaseService { /** * Pre-flights QBO for invoices whose DocNumber starts with the Assembly * invoice number and returns the lowest free slot (``, `-1`, …). - * Used by webhookInvoiceCreated to dodge 6240 collisions when a customer + * Used by webhookInvoiceCreated to dodge 6140 collisions when a customer * has already created an invoice with the same DocNumber in QBO manually. */ private async resolveAvailableDocNumber( @@ -771,7 +771,7 @@ export class InvoiceService extends BaseService { customer?.Id || existingCustomer?.qbCustomerId // Resolve a DocNumber that won't collide in QBO. Pre-flight a prefix - // query, pick the lowest free slot (``, `-1`, `-2`, …). On 6240 + // query, pick the lowest free slot (``, `-1`, `-2`, …). On 6140 // race (customer manually created the slot we picked between our query // and our create), re-walk once and retry. After that, throw and let // resync handle it. @@ -820,13 +820,13 @@ export class InvoiceService extends BaseService { } catch (err) { if (!isQBODuplicateDocNumberError(err)) throw err console.info( - `InvoiceService#webhookInvoiceCreated | 6240 on DocNumber=${docNumber}; re-walking once`, + `InvoiceService#webhookInvoiceCreated | 6140 on DocNumber=${docNumber}; re-walking once`, ) docNumber = await this.resolveAvailableDocNumber( intuitApiService, assemblyInvoiceNumber, ) - addSyncBreadcrumb('Retrying invoice creation in QBO after 6240', { + addSyncBreadcrumb('Retrying invoice creation in QBO after 6140', { invoiceNumber: assemblyInvoiceNumber, docNumber, }) diff --git a/src/app/api/quickbooks/invoice/invoice.utils.ts b/src/app/api/quickbooks/invoice/invoice.utils.ts index 3fe4bf8d..8fd7c3e1 100644 --- a/src/app/api/quickbooks/invoice/invoice.utils.ts +++ b/src/app/api/quickbooks/invoice/invoice.utils.ts @@ -1,3 +1,5 @@ +import { QBOErrorCodes } from '@/constant/intuitErrorCode' + export const formatAssemblyInvoicePrivateNote = ( invoiceNumber: string, ): string => `Assembly invoice: ${invoiceNumber}` @@ -39,8 +41,17 @@ export const findNextAvailableDocNumber = ( ) } +const DUP_DOC_NUMBER_CODE = QBOErrorCodes.DUPLICATE_DOC_NUMBER +// QBOErrorCode is a numeric literal union, so the interpolation below has +// no regex-special characters and needs no escaping. If that type ever +// widens to a string, escape the interpolation before using it here. +const DUP_DOC_NUMBER_PATTERN = new RegExp( + `${DUP_DOC_NUMBER_CODE}|Duplicate Document Number`, + 'i', // case insensitive +) + /** - * Recognizes QBO Error 6240 "Duplicate Document Number". Reads `.status`, + * Recognizes QBO's Duplicate Document Number error. Reads `.status`, * `.code`, `errors[].code`, and message text so it works whether the caller * surfaces the parsed APIError directly or wraps/normalises it. */ @@ -60,16 +71,19 @@ export const isQBODuplicateDocNumberError = (err: unknown): boolean => { Detail?: string Message?: string } - if (fault.code === 6240 || fault.code === '6240') return true + if (Number(fault.code) === DUP_DOC_NUMBER_CODE) return true if ( - /6240|Duplicate Document Number/i.test(fault.Detail ?? '') || - /6240|Duplicate Document Number/i.test(fault.Message ?? '') + DUP_DOC_NUMBER_PATTERN.test(fault.Detail ?? '') || + DUP_DOC_NUMBER_PATTERN.test(fault.Message ?? '') ) { return true } } } - if (e.status === 6240 || e.status === '6240') return true - if (e.code === 6240 || e.code === '6240') return true - return /6240|Duplicate Document Number/i.test(e.message ?? '') + if ( + Number(e.status) === DUP_DOC_NUMBER_CODE || + Number(e.code) === DUP_DOC_NUMBER_CODE + ) + return true + return DUP_DOC_NUMBER_PATTERN.test(e.message ?? '') } diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index f142f737..db1718a8 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -1,7 +1,16 @@ // Doc: https://developer.intuit.com/app/developer/qbo/docs/develop/troubleshooting/error-codes -export const AccountErrorCodes = [ - 6190, // account suspended - 6000, // business validation error +export const QBOErrorCodes = { + BUSINESS_VALIDATION: 6000, + DUPLICATE_DOC_NUMBER: 6140, + ACCOUNT_SUSPENDED: 6190, + DUPLICATE_NAME_EXISTS: 6240, // customer/vendor/employee name collision +} as const + +export type QBOErrorCode = (typeof QBOErrorCodes)[keyof typeof QBOErrorCodes] + +export const AccountErrorCodes: readonly number[] = [ + QBOErrorCodes.ACCOUNT_SUSPENDED, + QBOErrorCodes.BUSINESS_VALIDATION, ] export const OAuthErrorCodes = { diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 492292db..c8d3f5c2 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -68,7 +68,7 @@ export type IntuitAPITokensType = Pick< export const IntuitAPIErrorMessage = '#IntuitAPIErrorMessage#' -// APIError.status carries the QBO fault code (e.g. 6240) when available, +// APIError.status carries the QBO fault code (e.g. 6140) when available, // else BAD_REQUEST. withErrorHandler clamps non-HTTP codes downstream. export function assertNotQBFault(raw: unknown, opName: string): void { const result = QBFaultSchema.safeParse(raw) @@ -734,7 +734,7 @@ export default class IntuitAPI { * Returns all QBO invoices whose DocNumber starts with `prefix`. Used by * findNextAvailableDocNumber to detect collisions and pick the next free * suffix before createInvoice. Caps at maxresults=100; if a single prefix - * has more matches, the caller falls back to catch-6240 retry semantics. + * has more matches, the caller falls back to catch-6140 retry semantics. */ async _findInvoicesByDocNumberPrefix( prefix: string, diff --git a/test/integration/quickbooks/invoiceCreated/qboDocNumberCollision.test.ts b/test/integration/quickbooks/invoiceCreated/qboDocNumberCollision.test.ts index 64af9013..608eee3b 100644 --- a/test/integration/quickbooks/invoiceCreated/qboDocNumberCollision.test.ts +++ b/test/integration/quickbooks/invoiceCreated/qboDocNumberCollision.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { db } from '@/db' import { QBInvoiceSync } from '@/db/schema/qbInvoiceSync' +import { QBOErrorCodes } from '@/constant/intuitErrorCode' import invoiceCreatedPayload from '@test/fixtures/invoiceCreated.webhook' import { @@ -68,7 +69,12 @@ describe('POST /api/quickbooks/webhook — invoice.created (an invoice with this const duplicateError = Object.assign( new Error('Duplicate Document Number'), { - errors: [{ code: '6240', Detail: 'Duplicate Document Number error' }], + errors: [ + { + code: String(QBOErrorCodes.DUPLICATE_DOC_NUMBER), + Detail: 'Duplicate Document Number error', + }, + ], }, ) const createInvoice = vi diff --git a/test/unit/app/api/quickbooks/invoice/invoice.service.docNumber.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.service.docNumber.test.ts index 0bcafa06..a9b20aef 100644 --- a/test/unit/app/api/quickbooks/invoice/invoice.service.docNumber.test.ts +++ b/test/unit/app/api/quickbooks/invoice/invoice.service.docNumber.test.ts @@ -1,7 +1,7 @@ /** * Unit coverage for InvoiceService#resolveAvailableDocNumber — the pre-flight - * helper used by webhookInvoiceCreated to dodge 6240 collisions. The public - * retry-after-6240 branch is covered end-to-end in + * helper used by webhookInvoiceCreated to dodge 6140 collisions. The public + * retry-after-6140 branch is covered end-to-end in * test/integration/quickbooks/invoiceCreated/qboDocNumberCollision.test.ts; * this file pins the focused pieces in isolation. * diff --git a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts index 3c27128c..e01e3058 100644 --- a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts +++ b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts @@ -5,6 +5,10 @@ import { isQBODuplicateDocNumberError, MAX_SUFFIX_ATTEMPTS, } from '@/app/api/quickbooks/invoice/invoice.utils' +import { QBOErrorCodes } from '@/constant/intuitErrorCode' + +const DUP_DOC_NUMBER = QBOErrorCodes.DUPLICATE_DOC_NUMBER +const DUP_DOC_NUMBER_STR = String(DUP_DOC_NUMBER) describe('formatAssemblyInvoicePrivateNote', () => { it('formats an invoice number into the canonical PrivateNote string', () => { @@ -80,14 +84,14 @@ describe('findNextAvailableDocNumber', () => { describe('isQBODuplicateDocNumberError', () => { it('matches the real APIError shape thrown by intuitAPI._createInvoice', () => { - // This is the actual shape: status=400, message=boilerplate, errors=array - // of QBO fault objects. The 6240 code lives in errors[i].code. + // Actual shape: status=400, message=boilerplate, errors=array of QBO + // fault objects. The duplicate code lives in errors[i].code. const realApiError = { status: 400, message: '#IntuitAPIErrorMessage#createInvoice', errors: [ { - code: '6240', + code: DUP_DOC_NUMBER_STR, Message: 'Duplicate Document Number Error', Detail: 'Duplicate Document Number Error : You must specify a different number. This number has already been used.', @@ -102,7 +106,7 @@ describe('isQBODuplicateDocNumberError', () => { expect( isQBODuplicateDocNumberError({ status: 400, - errors: [{ code: 6240 }], + errors: [{ code: DUP_DOC_NUMBER }], }), ).toBe(true) }) @@ -116,12 +120,14 @@ describe('isQBODuplicateDocNumberError', () => { ).toBe(true) }) - it('matches top-level .status as 6240 (defense-in-depth)', () => { - expect(isQBODuplicateDocNumberError({ status: 6240 })).toBe(true) + it('matches the duplicate code at the top-level .status (defense-in-depth)', () => { + expect(isQBODuplicateDocNumberError({ status: DUP_DOC_NUMBER })).toBe(true) }) - it('matches top-level .code as 6240 (defense-in-depth)', () => { - expect(isQBODuplicateDocNumberError({ code: '6240' })).toBe(true) + it('matches the duplicate code at the top-level .code (defense-in-depth)', () => { + expect(isQBODuplicateDocNumberError({ code: DUP_DOC_NUMBER_STR })).toBe( + true, + ) }) it('matches top-level .message text (defense-in-depth)', () => {