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
8 changes: 4 additions & 4 deletions src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<n>`, `<n>-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(
Expand Down Expand Up @@ -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 (`<n>`, `<n>-1`, `<n>-2`, …). On 6240
// query, pick the lowest free slot (`<n>`, `<n>-1`, `<n>-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.
Expand Down Expand Up @@ -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,
})
Expand Down
28 changes: 21 additions & 7 deletions src/app/api/quickbooks/invoice/invoice.utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { QBOErrorCodes } from '@/constant/intuitErrorCode'

export const formatAssemblyInvoicePrivateNote = (
invoiceNumber: string,
): string => `Assembly invoice: ${invoiceNumber}`
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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 ?? '')
}
15 changes: 12 additions & 3 deletions src/constant/intuitErrorCode.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
4 changes: 2 additions & 2 deletions src/utils/intuitAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down
22 changes: 14 additions & 8 deletions test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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.',
Expand All @@ -102,7 +106,7 @@ describe('isQBODuplicateDocNumberError', () => {
expect(
isQBODuplicateDocNumberError({
status: 400,
errors: [{ code: 6240 }],
errors: [{ code: DUP_DOC_NUMBER }],
}),
).toBe(true)
})
Expand All @@ -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)', () => {
Expand Down
Loading