From 908ccf2881f16da1c8d159fa8bc37b8784a28191 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 10 Feb 2026 17:45:29 +0545 Subject: [PATCH] feat(OUT-3111): make customer name unique in QB with email --- .../quickbooks/customer/customer.service.ts | 89 ++++++++++++++++- .../api/quickbooks/invoice/invoice.service.ts | 97 +++++-------------- src/type/dto/intuitAPI.dto.ts | 15 +++ src/utils/intuitAPI.ts | 31 +++--- 4 files changed, 148 insertions(+), 84 deletions(-) diff --git a/src/app/api/quickbooks/customer/customer.service.ts b/src/app/api/quickbooks/customer/customer.service.ts index 948603c..fd4c596 100644 --- a/src/app/api/quickbooks/customer/customer.service.ts +++ b/src/app/api/quickbooks/customer/customer.service.ts @@ -11,8 +11,11 @@ import { QBCustomerUpdateSchemaType, } from '@/db/schema/qbCustomers' import { CompanyResponse, WhereClause } from '@/type/common' +import { QBCustomerCreatePayloadType } from '@/type/dto/intuitAPI.dto' +import { InvoiceCreatedResponseType } from '@/type/dto/webhook.dto' import { CopilotAPI } from '@/utils/copilotAPI' import IntuitAPI from '@/utils/intuitAPI' +import { replaceSpecialCharsForQB } from '@/utils/string' import { and, eq, isNull } from 'drizzle-orm' import httpStatus from 'http-status' @@ -262,7 +265,7 @@ export class CustomerService extends BaseService { Active: true, sparse: true, }) - customer = updateRes.Customer + customer = updateRes } // 2. update sync token in customer sync table @@ -319,4 +322,88 @@ export class CustomerService extends BaseService { 'displayName', ]) } + + /** + * Assembly supports the client with same name in same company. Since name should be unique in qb, we basically concat email address to the name + */ + private getCustomerDisplayName(recipientInfo: ClientCompanyType) { + let toConcat = '' + + if (recipientInfo.email) { + toConcat = `(${recipientInfo.email})` + } else { + toConcat = `(1)` + } + const finalDisplayName = `${replaceSpecialCharsForQB(recipientInfo.displayName)} ${toConcat}` + + return finalDisplayName.trim() + } + + async findOrCreateCustomerByName({ + intuitApiService, + recipientInfo, + companyInfo, + invoiceResource, + }: { + intuitApiService: IntuitAPI + recipientInfo: ClientCompanyType + companyInfo: CompanyResponse | undefined + invoiceResource: InvoiceCreatedResponseType['data'] + }) { + const displayName = this.getCustomerDisplayName(recipientInfo) + // 2.1. search client in qb using client's given name and family name + let customer = await intuitApiService.getACustomer( + displayName, + undefined, + true, + ) + + // 3. if not found, create a new client in the QB + if (!customer) { + console.info( + `InvoiceService#WebhookInvoiceCreated | Customer named ${recipientInfo.displayName} not found in QB. Creating new customer...`, + ) + // Create a new customer in QB + let customerPayload: QBCustomerCreatePayloadType = { + DisplayName: displayName, + CompanyName: companyInfo && replaceSpecialCharsForQB(companyInfo.name), + PrimaryEmailAddr: { + Address: recipientInfo?.email || '', + }, + } + + if (recipientInfo.givenName && recipientInfo.familyName) { + customerPayload = { + ...customerPayload, + GivenName: replaceSpecialCharsForQB(recipientInfo.givenName), + FamilyName: replaceSpecialCharsForQB(recipientInfo.familyName), + } + } + + const customerRes = await intuitApiService.createCustomer(customerPayload) + customer = customerRes + + console.info( + `InvoiceService#WebhookInvoiceCreated | Customer created in QB with ID: ${customer.Id}.`, + ) + } + + // create map for customer into mapping table + 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 + clientCompanyId: recipientInfo.clientCompanyId, + clientId: invoiceResource.clientId || null, + companyId: invoiceResource.companyId || null, + givenName: recipientInfo.givenName, + familyName: recipientInfo.familyName, + displayName: recipientInfo.displayName, + email: recipientInfo.email, + companyName: companyInfo?.name, + qbSyncToken: customer.SyncToken, + qbCustomerId: customer.Id, + }) + + return { customer, customerSyncId: customerSync.id } + } } diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 9378484..7ec070d 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -573,64 +573,14 @@ export class InvoiceService extends BaseService { let customer, existingCustomerMapId = existingCustomer?.id if (!existingCustomer) { - // 2.1. search client in qb using client's given name and family name - customer = await intuitApiService.getACustomer( - replaceSpecialCharsForQB(recipientInfo.displayName), - undefined, - true, - ) - - // 3. if not found, create a new client in the QB - if (!customer) { - console.info( - `InvoiceService#WebhookInvoiceCreated | Customer named ${recipientInfo.displayName} not found in QB. Creating new customer...`, - ) - // Create a new customer in QB - let customerPayload: QBCustomerCreatePayloadType = { - DisplayName: replaceSpecialCharsForQB(recipientInfo.displayName), - CompanyName: - companyInfo && replaceSpecialCharsForQB(companyInfo.name), - PrimaryEmailAddr: { - Address: recipientInfo?.email || '', - }, - } - - if (recipientInfo.givenName && recipientInfo.familyName) { - customerPayload = { - ...customerPayload, - GivenName: replaceSpecialCharsForQB(recipientInfo.givenName), - FamilyName: replaceSpecialCharsForQB(recipientInfo.familyName), - } - } - - const customerRes = - await intuitApiService.createCustomer(customerPayload) - customer = customerRes.Customer - - console.info( - `InvoiceService#WebhookInvoiceCreated | Customer created in QB with ID: ${customer.Id}.`, - ) - } - - // create map for customer into mapping table - const customerSyncPayload = { - portalId: this.user.workspaceId, - customerId: recipientInfo.recipientId, // TODO: remove everything related to this field. in case anything goes off the track - clientCompanyId: recipientInfo.clientCompanyId, - clientId: invoiceResource.clientId || null, - companyId: invoiceResource.companyId || null, - givenName: recipientInfo.givenName, - familyName: recipientInfo.familyName, - displayName: recipientInfo.displayName, - email: recipientInfo.email, - companyName: companyInfo?.name, - qbSyncToken: customer.SyncToken, - qbCustomerId: customer.Id, - } - - const customerSync = - await customerService.createQBCustomer(customerSyncPayload) - existingCustomerMapId = customerSync.id + const customerWName = await customerService.findOrCreateCustomerByName({ + intuitApiService, + recipientInfo, + companyInfo, + invoiceResource, + }) + customer = customerWName.customer + existingCustomerMapId = customerWName.customerSyncId } else { console.info('InvoiceService#webhookInvoiceCreated. Customer exists.') @@ -645,20 +595,26 @@ export class InvoiceService extends BaseService { Address: recipientInfo.email, } } - if (existingCustomer.displayName !== recipientInfo.displayName) { - // DisplayName = GivenName + FamilyName + CompanyName (if exists) - sparseUpdatePayload.DisplayName = replaceSpecialCharsForQB( - recipientInfo.displayName, - ) - sparseUpdatePayload.GivenName = replaceSpecialCharsForQB( - recipientInfo.givenName, - ) - sparseUpdatePayload.FamilyName = replaceSpecialCharsForQB( - recipientInfo.familyName, - ) + // if (existingCustomer.displayName !== recipientInfo.displayName) { + // // DisplayName = GivenName + FamilyName + CompanyName (if exists) + // sparseUpdatePayload.DisplayName = replaceSpecialCharsForQB( + // recipientInfo.displayName, + // ) + // sparseUpdatePayload.GivenName = replaceSpecialCharsForQB( + // recipientInfo.givenName, + // ) + // sparseUpdatePayload.FamilyName = replaceSpecialCharsForQB( + // recipientInfo.familyName, + // ) + // sparseUpdatePayload.CompanyName = + // companyInfo && replaceSpecialCharsForQB(companyInfo.name) + // } + + if (existingCustomer.companyName !== companyInfo?.name) { sparseUpdatePayload.CompanyName = companyInfo && replaceSpecialCharsForQB(companyInfo.name) } + if (Object.keys(sparseUpdatePayload).length > 0) { const customerSparsePayload = { ...sparseUpdatePayload, @@ -673,10 +629,9 @@ export class InvoiceService extends BaseService { sparse: true as const, } - const customerRes = await intuitApiService.customerSparseUpdate( + customer = await intuitApiService.customerSparseUpdate( customerSparsePayload, ) - customer = customerRes.Customer // update the customer map in our table const customerSyncUpPayload = { diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 91d5474..06b2f75 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -209,3 +209,18 @@ export const CompanyInfoSchema = z.object({ ), }) export type CompanyInfoType = z.infer + +export const CustomerQueryResponseSchema = z.object({ + Id: z.string(), + SyncToken: z.string(), + Active: z.boolean(), + PrimaryEmailAddr: z + .object({ + Address: z.string(), + }) + .optional(), +}) + +export type CustomerQueryResponseType = z.infer< + typeof CustomerQueryResponseSchema +> diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 6dd117f..c3f67e1 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -23,6 +23,8 @@ import { QBAccountResponseSchema, CompanyInfoType, CompanyInfoSchema, + CustomerQueryResponseType, + CustomerQueryResponseSchema, } from '@/type/dto/intuitAPI.dto' import { RetryableError } from '@/utils/error' import CustomLogger from '@/utils/logger' @@ -158,7 +160,9 @@ export default class IntuitAPI { return invoice } - async _createCustomer(payload: QBCustomerCreatePayloadType) { + async _createCustomer( + payload: QBCustomerCreatePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#createCustomer | customer creation start for realmId: ${this.tokens.intuitRealmId}. Payload: `, @@ -185,7 +189,7 @@ export default class IntuitAPI { obj: { response: customer.Customer }, message: `IntuitAPI#createCustomer | customer created with name = ${customer.Customer?.FullyQualifiedName}.`, }) - return customer + return customer.Customer } async _createItem(payload: QBItemCreatePayloadType) { @@ -253,17 +257,17 @@ export default class IntuitAPI { displayName: string, id?: undefined, includeInactive?: boolean, - ): Promise + ): Promise async _getACustomer( displayName: undefined, id: string, includeInactive?: boolean, - ): Promise + ): Promise async _getACustomer( displayName: string, id: string, includeInactive?: boolean, - ): Promise + ): Promise async _getACustomer( displayName?: string, id?: string, @@ -286,7 +290,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 FROM Customer WHERE ${queryCondition}` + const customerQuery = `SELECT Id, SyncToken, Active, PrimaryEmailAddr FROM Customer WHERE ${queryCondition}` const qbCustomers = await this.customQuery(customerQuery) if (!qbCustomers) return null @@ -300,7 +304,8 @@ export default class IntuitAPI { ) } - return qbCustomers.Customer?.[0] + if (!qbCustomers.Customer) return + return CustomerQueryResponseSchema.parse(qbCustomers.Customer[0]) } /** @@ -411,7 +416,9 @@ export default class IntuitAPI { return invoice } - async _customerSparseUpdate(payload: QBCustomerSparseUpdatePayloadType) { + async _customerSparseUpdate( + payload: QBCustomerSparseUpdatePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#customerSparseUpdate | customer sparse update start for realmId: ${this.tokens.intuitRealmId}. `, @@ -438,7 +445,7 @@ export default class IntuitAPI { obj: { response: customer.Customer }, message: `IntuitAPI#customerSparseUpdate | customer sparse updated with name = ${customer.Customer?.FullyQualifiedName}. `, }) - return customer + return customer.Customer } async _itemFullUpdate( @@ -814,17 +821,17 @@ export default class IntuitAPI { displayName: string, id?: undefined, includeInactive?: boolean, - ): Promise + ): Promise ( displayName: undefined, id: string, includeInactive?: boolean, - ): Promise + ): Promise ( displayName: string, id: string, includeInactive?: boolean, - ): Promise + ): Promise } = this.wrapWithRetry(this._getACustomer) as any getAnItem: { (