diff --git a/src/app/api/quickbooks/customer/customer.service.ts b/src/app/api/quickbooks/customer/customer.service.ts index 2c3e2052..bc0013cb 100644 --- a/src/app/api/quickbooks/customer/customer.service.ts +++ b/src/app/api/quickbooks/customer/customer.service.ts @@ -375,9 +375,22 @@ export class CustomerService extends BaseService { console.info( `InvoiceService#WebhookInvoiceCreated | Customer named ${recipientInfo.displayName} not found in QB. Creating new customer...`, ) - // Create a new customer in QB + // Create a new customer in QB. + // QB enforces DisplayName uniqueness across Customer, Vendor, and Employee. + // Resolve a free DisplayName up-front to avoid 6240/6200 errors when the + // base name or its "(Customer)" suffixed form is already taken. + const sanitizedDisplayName = replaceSpecialCharsForQB(displayName) + const finalDisplayName = + await intuitApiService.resolveUniqueCustomerName(sanitizedDisplayName) + + if (finalDisplayName !== sanitizedDisplayName) { + console.info( + `InvoiceService#WebhookInvoiceCreated | DisplayName "${sanitizedDisplayName}" was taken; resolved to "${finalDisplayName}".`, + ) + } + let customerPayload: QBCustomerCreatePayloadType = { - DisplayName: replaceSpecialCharsForQB(displayName), + DisplayName: finalDisplayName, CompanyName: companyInfo && replaceSpecialCharsForQB(companyInfo.name), PrimaryEmailAddr: { Address: recipientInfo.email, @@ -404,6 +417,9 @@ export class CustomerService extends BaseService { } // create map for customer into mapping table + // NOTE: displayName here is the Copilot-side name (may differ from the QB + // DisplayName when a collision suffix was applied). The source of truth + // for the QB record's DisplayName is QB itself (fetched via qbCustomerId). const customerSync = await this.createQBCustomer({ portalId: this.user.workspaceId, customerId: recipientInfo.recipientId, // TODO: remove everything related to this field. in case anything goes off the track diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 1bfdc850..732a54d4 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -28,7 +28,7 @@ import { QBItemsResponseSchema, SingleIdAndTokenResponseSchema, } from '@/type/dto/intuitAPI.dto' -import { escapeForQBQuery } from '@/utils/string' +import { escapeForQBQuery, getNameAsCustomer } from '@/utils/string' import { RetryableError } from '@/utils/error' import CustomLogger from '@/utils/logger' import httpStatus from 'http-status' @@ -63,6 +63,11 @@ export type ItemResponseType = BaseResponseType & { export const IntuitAPIErrorMessage = '#IntuitAPIErrorMessage#' +// Upper bound on the " (Customer) N" suffix counter when resolving a unique +// DisplayName. If exceeded, creation is aborted and a human is paged — having +// 20+ Copilot clients sharing a single display name is pathological. +const CUSTOMER_NAME_MAX_CANDIDATES = 20 + export default class IntuitAPI { tokens: IntuitAPITokensType private headers: Record @@ -238,18 +243,6 @@ export default class IntuitAPI { 'IntuitAPI#getSingleIncomeAccount | Income account not found', ) - if (qbIncomeAccountRefInfo?.Fault) { - CustomLogger.error({ - obj: qbIncomeAccountRefInfo.Fault?.Error, - message: 'Error: ', - }) - throw new APIError( - qbIncomeAccountRefInfo.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}getSingleIncomeAccount`, - qbIncomeAccountRefInfo.Fault?.Error, - ) - } - return qbIncomeAccountRefInfo.Account?.[0] } @@ -299,15 +292,6 @@ export default class IntuitAPI { if (!qbCustomers) return null - if (qbCustomers?.Fault) { - CustomLogger.error({ obj: qbCustomers.Fault?.Error, message: 'Error: ' }) - throw new APIError( - qbCustomers.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}getACustomer`, - qbCustomers.Fault?.Error, - ) - } - if (!qbCustomers.Customer) return return CustomerQueryResponseSchema.parse(qbCustomers.Customer[0]) } @@ -324,17 +308,75 @@ export default class IntuitAPI { if (!qbCustomers) return - if (qbCustomers?.Fault) { - CustomLogger.error({ obj: qbCustomers.Fault?.Error, message: 'Error: ' }) + if (!qbCustomers.Customer) return + return CustomerQueryResponseSchema.parse(qbCustomers.Customer[0]) + } + + /** + * Resolves a DisplayName that is free across Customer, Vendor, and Employee + * (QBO enforces uniqueness across all three). Tries the baseName first, then + * "baseName (Customer)", "baseName (Customer) 2", ..., up to + * CUSTOMER_NAME_MAX_CANDIDATES. Queries all three entities in parallel with + * DisplayName IN (...) and picks the first candidate not in the returned set. + * + * Inactive records are intentionally excluded: QBO auto-suffixes their + * DisplayName with " (deleted)" when deactivated, freeing the original name. + * + * Intentionally NOT wrapped in wrapWithRetry — the inner customQuery calls + * already retry on 429; re-wrapping would amplify rate-limit bursts (worst + * case 4 × 3 × 4 = 48 requests) and make recovery worse. + * + * Throws if every candidate is taken. + */ + async resolveUniqueCustomerName(baseName: string): Promise { + const suffixed = getNameAsCustomer(baseName) + const candidates = [baseName, suffixed] + for (let i = 2; i <= CUSTOMER_NAME_MAX_CANDIDATES; i++) { + candidates.push(`${suffixed} ${i}`) + } + + const escapedList = candidates + .map((c) => `'${escapeForQBQuery(c)}'`) + .join(', ') + + CustomLogger.info({ + message: `IntuitAPI#resolveUniqueCustomerName | Resolving unique DisplayName for realmId: ${this.tokens.intuitRealmId}. Base: "${baseName}"`, + }) + + const [customerRes, vendorRes, employeeRes] = await Promise.all([ + this.customQuery( + `SELECT DisplayName FROM Customer WHERE DisplayName IN (${escapedList})`, + ), + this.customQuery( + `SELECT DisplayName FROM Vendor WHERE DisplayName IN (${escapedList})`, + ), + this.customQuery( + `SELECT DisplayName FROM Employee WHERE DisplayName IN (${escapedList})`, + ), + ]) + + // Case-insensitive comparison: QBO's DisplayName equality is case- + // insensitive, so a returned record may differ in case from our candidate. + const usedNames = new Set() + for (const c of customerRes?.Customer ?? []) { + usedNames.add(c.DisplayName.toLowerCase()) + } + for (const v of vendorRes?.Vendor ?? []) { + usedNames.add(v.DisplayName.toLowerCase()) + } + for (const e of employeeRes?.Employee ?? []) { + usedNames.add(e.DisplayName.toLowerCase()) + } + + const freeName = candidates.find((c) => !usedNames.has(c.toLowerCase())) + if (!freeName) { throw new APIError( - qbCustomers.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}getCustomerByEmail`, - qbCustomers.Fault?.Error, + httpStatus.CONFLICT, + `${IntuitAPIErrorMessage}resolveUniqueCustomerName | All ${CUSTOMER_NAME_MAX_CANDIDATES} candidate names are taken for base "${baseName}"`, ) } - if (!qbCustomers.Customer) return - return CustomerQueryResponseSchema.parse(qbCustomers.Customer[0]) + return freeName } /** @@ -377,15 +419,6 @@ export default class IntuitAPI { if (!qbItem) return null - if (qbItem?.Fault) { - CustomLogger.error({ obj: qbItem.Fault?.Error, message: 'Error: ' }) - throw new APIError( - qbItem.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}getAnItem`, - qbItem.Fault?.Error, - ) - } - return qbItem.Item?.[0] } @@ -403,15 +436,6 @@ export default class IntuitAPI { if (!qbItems) return null - if (qbItems?.Fault) { - CustomLogger.error({ obj: qbItems.Fault?.Error, message: 'Error: ' }) - throw new APIError( - qbItems.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}getAllItems`, - qbItems.Fault?.Error, - ) - } - return QBItemsResponseSchema.parse(qbItems.Item || []) } @@ -589,15 +613,6 @@ export default class IntuitAPI { 'IntuitAPI#getInvoice | message = no response', ) - if (invoice?.Fault) { - CustomLogger.error({ obj: invoice.Fault?.Error, message: 'Error: ' }) - throw new APIError( - invoice.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}getInvoice`, - invoice.Fault?.Error, - ) - } - if (!invoice.Invoice) return null CustomLogger.info({ @@ -738,15 +753,6 @@ export default class IntuitAPI { if (!customQuery) return null - if (customQuery?.Fault) { - CustomLogger.error({ obj: customQuery.Fault?.Error, message: 'Error: ' }) - throw new APIError( - customQuery.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}getAnAccount`, - customQuery.Fault?.Error, - ) - } - return customQuery.Account?.[0] } @@ -854,15 +860,6 @@ export default class IntuitAPI { true, ) - if (companyInfo.Fault) { - CustomLogger.error({ obj: companyInfo.Fault?.Error, message: 'Error: ' }) - throw new APIError( - companyInfo.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}getCompanyInfo`, - companyInfo.Fault?.Error, - ) - } - const parsedCompanyInfo = CompanyInfoSchema.parse(companyInfo) return parsedCompanyInfo.CompanyInfo[0] } diff --git a/src/utils/string.ts b/src/utils/string.ts index 59db06bd..3f1c0cb6 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -68,6 +68,21 @@ export function truncateForQB(input: string, suffix?: string): string { return input.slice(0, maxBaseLength) + ELLIPSIS + suffix } +const CUSTOMER_SUFFIX = ' (Customer)' + +/** + * Appends " (Customer)" to a DisplayName to disambiguate it from a colliding + * Vendor/Employee. QBO enforces DisplayName uniqueness across those three + * entities, so a Customer that shares a name with a Vendor needs a distinct + * name. No-op if the suffix is already present. + */ +export function getNameAsCustomer(name: string): string { + if (name.endsWith(CUSTOMER_SUFFIX)) { + return name + } + return `${name}${CUSTOMER_SUFFIX}` +} + export function replaceSpecialCharsForQB(input: string) { // list of allowed characters in QB. // Doc: https://quickbooks.intuit.com/learn-support/en-us/help-article/account-management/acceptable-characters-quickbooks-online/L3CiHlD9J_US_en_US