Skip to content
Draft
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
89 changes: 88 additions & 1 deletion src/app/api/quickbooks/customer/customer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
}
}
97 changes: 26 additions & 71 deletions src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import { TransactionType, WhereClause } from '@/type/common'
import {
QBCustomerSparseUpdatePayloadType,
QBCustomerCreatePayloadType,

Check warning on line 31 in src/app/api/quickbooks/invoice/invoice.service.ts

View workflow job for this annotation

GitHub Actions / Run linters

'QBCustomerCreatePayloadType' is defined but never used. Allowed unused vars must match /^_/u
QBDestructiveInvoicePayloadSchema,
QBNameValueSchemaType,
QBInvoiceLineItemSchemaType,
Expand Down Expand Up @@ -573,64 +573,14 @@
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.')

Expand All @@ -645,20 +595,26 @@
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,
Expand All @@ -673,10 +629,9 @@
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 = {
Expand Down
15 changes: 15 additions & 0 deletions src/type/dto/intuitAPI.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,18 @@ export const CompanyInfoSchema = z.object({
),
})
export type CompanyInfoType = z.infer<typeof CompanyInfoSchema>

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
>
31 changes: 19 additions & 12 deletions src/utils/intuitAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -158,7 +160,9 @@ export default class IntuitAPI {
return invoice
}

async _createCustomer(payload: QBCustomerCreatePayloadType) {
async _createCustomer(
payload: QBCustomerCreatePayloadType,
): Promise<CustomerQueryResponseType> {
CustomLogger.info({
obj: { payload },
message: `IntuitAPI#createCustomer | customer creation start for realmId: ${this.tokens.intuitRealmId}. Payload: `,
Expand All @@ -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) {
Expand Down Expand Up @@ -253,17 +257,17 @@ export default class IntuitAPI {
displayName: string,
id?: undefined,
includeInactive?: boolean,
): Promise<BaseResponseType>
): Promise<CustomerQueryResponseType>
async _getACustomer(
displayName: undefined,
id: string,
includeInactive?: boolean,
): Promise<BaseResponseType>
): Promise<CustomerQueryResponseType>
async _getACustomer(
displayName: string,
id: string,
includeInactive?: boolean,
): Promise<BaseResponseType>
): Promise<CustomerQueryResponseType>
async _getACustomer(
displayName?: string,
id?: string,
Expand All @@ -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
Expand All @@ -300,7 +304,8 @@ export default class IntuitAPI {
)
}

return qbCustomers.Customer?.[0]
if (!qbCustomers.Customer) return
return CustomerQueryResponseSchema.parse(qbCustomers.Customer[0])
}

/**
Expand Down Expand Up @@ -411,7 +416,9 @@ export default class IntuitAPI {
return invoice
}

async _customerSparseUpdate(payload: QBCustomerSparseUpdatePayloadType) {
async _customerSparseUpdate(
payload: QBCustomerSparseUpdatePayloadType,
): Promise<CustomerQueryResponseType> {
CustomLogger.info({
obj: { payload },
message: `IntuitAPI#customerSparseUpdate | customer sparse update start for realmId: ${this.tokens.intuitRealmId}. `,
Expand All @@ -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(
Expand Down Expand Up @@ -814,17 +821,17 @@ export default class IntuitAPI {
displayName: string,
id?: undefined,
includeInactive?: boolean,
): Promise<BaseResponseType>
): Promise<CustomerQueryResponseType>
(
displayName: undefined,
id: string,
includeInactive?: boolean,
): Promise<BaseResponseType>
): Promise<CustomerQueryResponseType>
(
displayName: string,
id: string,
includeInactive?: boolean,
): Promise<BaseResponseType>
): Promise<CustomerQueryResponseType>
} = this.wrapWithRetry(this._getACustomer) as any
getAnItem: {
(
Expand Down
Loading