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
10 changes: 9 additions & 1 deletion src/app/api/core/utils/withErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,15 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => {
})
}

return NextResponse.json({ error: message, errors }, { status })
// QBO fault codes (e.g. 6240) aren't valid HTTP statuses; clamp before
// NextResponse.json. Original code is preserved in `errors`.
const httpStatusOut =
status >= 200 && status <= 599 ? status : httpStatus.BAD_REQUEST

return NextResponse.json(
{ error: message, errors },
{ status: httpStatusOut },
)
}
}
}
5 changes: 3 additions & 2 deletions src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1211,12 +1211,13 @@ export class InvoiceService extends BaseService {
)
}

// Copilot doesn't allow to delete invoice that are not voided. So, just log an error about possible edge cases without returning an error
// Copilot only fires delete on voided invoices; surface any other state
// as a FAILED log via the webhook catch.
if (syncedInvoice.status !== InvoiceStatus.VOID) {
console.error(
'InvoiceService#handleInvoiceDeleted | Invoices delete was requested for non-voided record',
)
return // return early if invoice is not voided
throw new Error('Invoices delete was requested for non-voided record')
}

// get invoice sync log
Expand Down
17 changes: 3 additions & 14 deletions src/app/api/quickbooks/invoice/invoice.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,9 @@ export const findNextAvailableDocNumber = (
}

/**
* Recognizes QBO Error 6240 "Duplicate Document Number" across the shapes it
* surfaces in.
*
* Live path: APIError thrown from intuitAPI._createInvoice carries the QBO
* fault payload in its `errors` array (`{ code: '6240', Detail, Message }`).
* APIError.status lands as 400 because intuitAPI dereferences
* `Fault.Error?.code` as if it were an object (the QBO Fault.Error is an
* array); APIError.message is the boilerplate `#IntuitAPIErrorMessage#…`.
* So the only reliable signal is iterating `errors[]` and matching `code`
* or the Detail/Message text.
*
* Defense-in-depth: also check top-level .status/.code/.message in case any
* future call site rethrows the inner fault directly or normalizes the
* APIError differently.
* Recognizes QBO Error 6240 "Duplicate Document Number". Reads `.status`,
* `.code`, `errors[].code`, and message text so it works whether the caller
* surfaces the parsed APIError directly or wraps/normalises it.
*/
export const isQBODuplicateDocNumberError = (err: unknown): boolean => {
if (!err || typeof err !== 'object' || Array.isArray(err)) return false
Expand Down
21 changes: 17 additions & 4 deletions src/type/dto/intuitAPI.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,25 @@ export const QBNameValueSchema = z.object({
})
export type QBNameValueSchemaType = z.infer<typeof QBNameValueSchema>

// QBO returns Fault on any failed response. Error is loose (object or array)
// across endpoints; we forward whatever shape arrived so callers/log
// consumers can inspect it.
// QBO sends `code` as a string. Coerce to number, or to undefined for
// missing / null / empty / non-numeric values — never NaN, never a parse
// failure, so assertNotQBFault can't silently swallow a Fault.
export const QBFaultErrorSchema = z.object({
code: z.unknown().transform((v) => {
if (v === undefined || v === null || v === '') return undefined
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n : undefined
}),
Message: z.string().optional(),
Detail: z.string().optional(),
element: z.string().optional(),
})
Comment thread
SandipBajracharya marked this conversation as resolved.
export type QBFaultErrorSchemaType = z.infer<typeof QBFaultErrorSchema>

export const QBFaultSchema = z.object({
Fault: z.object({
Error: z.unknown().optional(),
// .default([]) prevents a silent no-op when Fault is present but Error is absent.
Error: z.array(QBFaultErrorSchema).optional().default([]),
Comment thread
SandipBajracharya marked this conversation as resolved.
}),
})
export type QBFaultType = z.infer<typeof QBFaultSchema>
Expand Down
4 changes: 3 additions & 1 deletion src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@/app/api/core/exceptions/custom'
import { OAuthErrorCodes } from '@/constant/intuitErrorCode'
import { CopilotApiError, MessagableError } from '@/type/CopilotApiError'
import { QBFaultErrorSchemaType } from '@/type/dto/intuitAPI.dto'
import { refreshTokenExpireMessage } from '@/utils/auth'
import { IntuitAPIErrorMessage } from '@/utils/intuitAPI'
import httpStatus from 'http-status'
Expand Down Expand Up @@ -50,7 +51,8 @@ export const getMessageAndCodeFromError = (
let errorMessage = error.message || message
const isIntuitError = error.message.includes(IntuitAPIErrorMessage)
if (isIntuitError) {
errorMessage = (error.errors?.[0] as IntuitErrorType).Detail
const firstFault = error.errors?.[0] as QBFaultErrorSchemaType | undefined
errorMessage = firstFault?.Detail ?? errorMessage
}
return {
message: errorMessage,
Expand Down
93 changes: 60 additions & 33 deletions src/utils/intuitAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
CompanyInfoType,
CompanyInfoSchema,
CustomerQueryResponseType,
CustomerQueryResponseSchema,
QBCustomerResponseSchema,
QBItemsResponseSchema,
QBItemsResponseType,
Expand All @@ -46,6 +45,7 @@ import {
SingleIdAndTokenResponseSchema,
SingleIdAndTokenResponseType,
QBInvoiceQueryResponseSchema,
QBInvoiceRowType,
CustomerListEnvelopeSchema,
QBFaultSchema,
} from '@/type/dto/intuitAPI.dto'
Expand All @@ -68,33 +68,60 @@ export type IntuitAPITokensType = Pick<

export const IntuitAPIErrorMessage = '#IntuitAPIErrorMessage#'

// Throws an APIError if `raw` is a QBO Fault response; no-op otherwise.
// Replaces the duplicated `if (raw?.Fault) throw ...` block in every method.
// Fault.Error.code is preserved only when numeric (HTTP-safe); QBO's
// string codes fall back to BAD_REQUEST as before.
// APIError.status carries the QBO fault code (e.g. 6240) when available,
// else BAD_REQUEST. withErrorHandler clamps non-HTTP codes downstream.
export function assertNotQBFault(raw: unknown, opName: string): void {
const result = QBFaultSchema.safeParse(raw)
if (!result.success) return
const error = result.data.Fault.Error
CustomLogger.error({ obj: error, message: 'Error: ' })
const errors = result.data.Fault.Error
CustomLogger.error({ obj: errors, message: 'Error: ' })
const firstCode = errors[0]?.code
const code =
error &&
typeof error === 'object' &&
!Array.isArray(error) &&
'code' in error &&
typeof (error as { code: unknown }).code === 'number'
? (error as { code: number }).code
: httpStatus.BAD_REQUEST
// APIError.errors is typed unknown[] — pass array Errors verbatim, drop
// non-array shapes to undefined rather than casting (the message + the
// logged error above retain the diagnostic detail).
throw new APIError(
code,
`${IntuitAPIErrorMessage}${opName}`,
Array.isArray(error) ? error : undefined,
)
typeof firstCode === 'number' ? firstCode : httpStatus.BAD_REQUEST
throw new APIError(code, `${IntuitAPIErrorMessage}${opName}`, errors)
}

// SELECT projection for each read entity. `satisfies` binds the constant to
// the row schema's keys — a typo or stale field name fails tsc. Keep in
// sync with the row schemas in src/type/dto/intuitAPI.dto.ts.
export const QB_INVOICE_COLUMNS = [
'Id',
'SyncToken',
'DocNumber',
'Balance',
'TotalAmt',
'TxnDate',
'DueDate',
'PrivateNote',
'CustomerRef',
] as const satisfies ReadonlyArray<keyof QBInvoiceRowType>

export const QB_ITEM_COLUMNS = [
'Id',
'SyncToken',
'Name',
'ClassRef',
'Active',
'UnitPrice',
'Description',
] as const satisfies ReadonlyArray<keyof QBItemRowType>

export const QB_CUSTOMER_COLUMNS = [
'Id',
'SyncToken',
'Active',
'CompanyName',
'FullyQualifiedName',
'PrimaryEmailAddr',
] as const satisfies ReadonlyArray<keyof CustomerQueryResponseType>

export const QB_ACCOUNT_COLUMNS = [
'Id',
'Name',
'SyncToken',
'Active',
] as const satisfies ReadonlyArray<keyof QBAccountRowType>

type GetACustomerOverloads = {
(
displayName: string,
Expand Down Expand Up @@ -293,7 +320,7 @@ export default class IntuitAPI {
CustomLogger.info({
message: `IntuitAPI#getSingleIncomeAccount | Income account query start for realmId: ${this.tokens.intuitRealmId}`,
})
const sqlQuery = `SELECT Id, Name, SyncToken, Active FROM Account WHERE AccountType = 'Income' AND AccountSubType = 'SalesOfProductIncome' AND Active = true maxresults 1`
const sqlQuery = `select ${QB_ACCOUNT_COLUMNS.join(', ')} from Account where AccountType = 'Income' AND AccountSubType = 'SalesOfProductIncome' AND Active = true maxresults 1`
const qbIncomeAccountRefInfo = await this.customQuery(sqlQuery)

if (!qbIncomeAccountRefInfo)
Expand Down Expand Up @@ -347,7 +374,7 @@ export default class IntuitAPI {
CustomLogger.info({
message: `IntuitAPI#getACustomer | Customer query start for realmId: ${this.tokens.intuitRealmId}. Name: ${displayName}, Id: ${id}`,
})
const customerQuery = `SELECT Id, SyncToken, Active, CompanyName, PrimaryEmailAddr FROM Customer WHERE ${queryCondition}`
const customerQuery = `select ${QB_CUSTOMER_COLUMNS.join(', ')} from Customer where ${queryCondition}`
const qbCustomers = await this.customQuery(customerQuery)

if (!qbCustomers) return null
Expand Down Expand Up @@ -378,7 +405,7 @@ export default class IntuitAPI {
let startPosition = 1

while (true) {
const customerQuery = `SELECT Id, SyncToken, Active, CompanyName, PrimaryEmailAddr FROM Customer WHERE Active IN (true, false) ORDERBY Id ASC STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}`
const customerQuery = `select ${QB_CUSTOMER_COLUMNS.join(', ')} from Customer where Active IN (true, false) ORDERBY Id ASC STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}`
const qbCustomers = await this.customQuery(customerQuery)
const envelope = CustomerListEnvelopeSchema.parse(qbCustomers ?? {})
const customers = envelope.Customer ?? []
Expand Down Expand Up @@ -518,7 +545,7 @@ export default class IntuitAPI {
CustomLogger.info({
message: `IntuitAPI#getAnItem | Item query start for realmId: ${this.tokens.intuitRealmId}. Condition: ${queryCondition}`,
})
const customerQuery = `select Id, SyncToken, ClassRef, Active, Name, UnitPrice from Item where ${queryCondition} maxresults 1`
const customerQuery = `select ${QB_ITEM_COLUMNS.join(', ')} from Item where ${queryCondition} maxresults 1`
const qbItem = await this.customQuery(customerQuery)

if (!qbItem) return null
Expand All @@ -531,9 +558,8 @@ export default class IntuitAPI {
CustomLogger.info({
message: `IntuitAPI#getAllItems | Item query start for realmId: ${this.tokens.intuitRealmId}`,
})
// Columns are fixed to match QBItemsResponseSchema; callers don't need
// to know the projection. Service items only.
const customerQuery = `select Id, Name, UnitPrice, Description, SyncToken from Item where Type = 'Service' maxresults ${limit}`
// Service items only.
const customerQuery = `select ${QB_ITEM_COLUMNS.join(', ')} from Item where Type = 'Service' maxresults ${limit}`
CustomLogger.info({
obj: { customerQuery },
message: 'IntuitAPI#getAllItems',
Expand Down Expand Up @@ -685,7 +711,7 @@ export default class IntuitAPI {
obj: { invoiceNumber },
message: `IntuitAPI#getInvoice | invoice query start for realmId: ${this.tokens.intuitRealmId}. `,
})
const query = `select Id, SyncToken, DocNumber from Invoice where DocNumber = '${escapeForQBQuery(invoiceNumber)}' maxresults 1`
const query = `select ${QB_INVOICE_COLUMNS.join(', ')} from Invoice where DocNumber = '${escapeForQBQuery(invoiceNumber)}' maxresults 1`
const invoice = await this.customQuery(query)

if (!invoice)
Expand All @@ -712,13 +738,13 @@ export default class IntuitAPI {
*/
async _findInvoicesByDocNumberPrefix(
prefix: string,
): Promise<Array<{ Id: string; DocNumber: string }>> {
): Promise<Array<{ Id: string; DocNumber: string; SyncToken: string }>> {
// LIKE-wildcard chars in user input would broaden the match. Assembly
// invoice numbers don't contain '%' or '_', but escape defensively.
const escapedPrefix = escapeForQBQuery(prefix)
.replace(/%/g, '\\%')
.replace(/_/g, '\\_')
const query = `select Id, SyncToken, DocNumber from Invoice where DocNumber LIKE '${escapedPrefix}%' maxresults 100`
const query = `select ${QB_INVOICE_COLUMNS.join(', ')} from Invoice where DocNumber LIKE '${escapedPrefix}%' maxresults 100`
const response = await this.customQuery(query)
if (!response) {
throw new APIError(
Expand All @@ -731,6 +757,7 @@ export default class IntuitAPI {
return envelope.Invoice.map((inv) => ({
Id: inv.Id,
DocNumber: inv.DocNumber ?? '',
SyncToken: inv.SyncToken,
}))
}

Expand Down Expand Up @@ -848,7 +875,7 @@ export default class IntuitAPI {
: `Id = '${id}'`
queryCondition = `${queryCondition} AND Active IN (true${includeInactive ? ', false' : ''})` // By default, QB returns only active items.

const query = `SELECT Id, SyncToken, Active, Name FROM Account where ${queryCondition}`
const query = `select ${QB_ACCOUNT_COLUMNS.join(', ')} from Account where ${queryCondition}`
const customQueryRes = await this.customQuery(query)

if (!customQueryRes) return null
Expand Down
83 changes: 83 additions & 0 deletions test/unit/utils/intuitAPI.queryColumns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Asserts each QB_*_COLUMNS constant stays a superset of its schema's
// required keys. Optional-field drops still pass — verify callers if you
// remove one.

import { describe, it, expect } from 'vitest'
import {
QB_ACCOUNT_COLUMNS,
QB_CUSTOMER_COLUMNS,
QB_INVOICE_COLUMNS,
QB_ITEM_COLUMNS,
} from '@/utils/intuitAPI'
import {
CustomerQueryResponseSchema,
QBAccountRowSchema,
QBInvoiceRowSchema,
QBItemRowSchema,
} from '@/type/dto/intuitAPI.dto'
import type { ZodTypeAny } from 'zod'

const requiredKeysOf = (shape: Record<string, ZodTypeAny>): string[] =>
Object.entries(shape)
.filter(([, s]) => !s.isOptional())
.map(([k]) => k)

describe('QB_INVOICE_COLUMNS', () => {
it('selects every column the invoice schema marks as required', () => {
for (const key of requiredKeysOf(QBInvoiceRowSchema.shape)) {
expect(QB_INVOICE_COLUMNS).toContain(key)
}
})

it('does not list any column that the invoice schema does not define', () => {
const schemaKeys = new Set(Object.keys(QBInvoiceRowSchema.shape))
for (const col of QB_INVOICE_COLUMNS) {
expect(schemaKeys.has(col)).toBe(true)
}
})
})

describe('QB_ITEM_COLUMNS', () => {
it('selects every column the item schema marks as required', () => {
for (const key of requiredKeysOf(QBItemRowSchema.shape)) {
expect(QB_ITEM_COLUMNS).toContain(key)
}
})

it('does not list any column that the item schema does not define', () => {
const schemaKeys = new Set(Object.keys(QBItemRowSchema.shape))
for (const col of QB_ITEM_COLUMNS) {
expect(schemaKeys.has(col)).toBe(true)
}
})
})

describe('QB_CUSTOMER_COLUMNS', () => {
it('selects every column the customer schema marks as required', () => {
for (const key of requiredKeysOf(CustomerQueryResponseSchema.shape)) {
expect(QB_CUSTOMER_COLUMNS).toContain(key)
}
})

it('does not list any column that the customer schema does not define', () => {
const schemaKeys = new Set(Object.keys(CustomerQueryResponseSchema.shape))
for (const col of QB_CUSTOMER_COLUMNS) {
expect(schemaKeys.has(col)).toBe(true)
}
})
})

describe('QB_ACCOUNT_COLUMNS', () => {
it('selects every column the account schema marks as required', () => {
for (const key of requiredKeysOf(QBAccountRowSchema.shape)) {
expect(QB_ACCOUNT_COLUMNS).toContain(key)
}
})

it('does not list any column that the account schema does not define', () => {
const schemaKeys = new Set(Object.keys(QBAccountRowSchema.shape))
for (const col of QB_ACCOUNT_COLUMNS) {
expect(schemaKeys.has(col)).toBe(true)
}
})
})
Loading
Loading