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
20 changes: 8 additions & 12 deletions src/app/api/quickbooks/customer/customer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,26 +346,22 @@ export class CustomerService extends BaseService {
invoiceResource: InvoiceCreatedResponseType['data']
}) {
const displayName = recipientInfo.displayName
const sanitizedCompanyName = recipientInfo.companyName
? replaceSpecialCharsForQB(recipientInfo.companyName)
: undefined

// 2.1. search client in qb using recipient's email or display name
let customer = recipientInfo.email
? await intuitApiService.getCustomerByEmail(recipientInfo.email)
? await intuitApiService.getCustomerByEmail(
recipientInfo.email,
sanitizedCompanyName,
)
: await intuitApiService.getACustomer(
replaceSpecialCharsForQB(recipientInfo.displayName),
undefined,
true,
)

// 2.2. verify the matched customer has the same company name. This is needed because a single customer with same email can be part of multiple companies
const sanitizedCompanyName = recipientInfo.companyName
? replaceSpecialCharsForQB(recipientInfo.companyName)
: undefined
if (
customer &&
(customer.CompanyName || undefined) !== sanitizedCompanyName
) {
customer = undefined
}

addSyncBreadcrumb('Customer search in QBO', {
found: !!customer,
})
Expand Down
41 changes: 34 additions & 7 deletions src/utils/intuitAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,20 +296,44 @@ export default class IntuitAPI {
return CustomerQueryResponseSchema.parse(qbCustomers.Customer[0])
}

// QBO's parser mishandles special chars on PrimaryEmailAddr filters, so we
// page and match client-side. sanitizedCompanyName disambiguates the same
// email across companies; normalisation matches customer.service.ts.
// ORDERBY Id ASC pins a stable cursor — QBO's default (LastUpdatedTime DESC)
// lets a mid-walk update shift a row past STARTPOSITION (false negative).
async _getCustomerByEmail(
email: string,
sanitizedCompanyName?: string,
): Promise<CustomerQueryResponseType | undefined> {
const needle = email.trim().toLowerCase()
if (!needle) return

CustomLogger.info({
obj: { email },
obj: { email, sanitizedCompanyName },
message: `IntuitAPI#getCustomerByEmail | Customer query start for realmId: ${this.tokens.intuitRealmId}. Email: ${email}`,
})
const customerQuery = `SELECT Id, SyncToken, Active, CompanyName, PrimaryEmailAddr FROM Customer WHERE PrimaryEmailAddr = '${escapeForQBQuery(email)}' AND Active in (true, false)`
const qbCustomers = await this.customQuery(customerQuery)

if (!qbCustomers) return
const pageSize = 1000
let startPosition = 1

if (!qbCustomers.Customer) return
return CustomerQueryResponseSchema.parse(qbCustomers.Customer[0])
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 qbCustomers = await this.customQuery(customerQuery)
const customers = qbCustomers?.Customer ?? []
if (customers.length === 0) return

const match = customers.find((c: CustomerQueryResponseType) => {
const addr = c.PrimaryEmailAddr?.Address
if (typeof addr !== 'string') return false
if (addr.trim().toLowerCase() !== needle) return false
if ((c.CompanyName || undefined) !== sanitizedCompanyName) return false
Comment thread
priosshrsth marked this conversation as resolved.
return true
Comment on lines +329 to +330
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ((c.CompanyName || undefined) !== sanitizedCompanyName) return false
return true
return c.CompanyName === sanitizedCompanyName

Copy link
Copy Markdown
Collaborator Author

@SandipBajracharya SandipBajracharya May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompanyName can be empty string when returned from QBO. Since the value for sanitizedCompanyName is either string or optional, comparing CompanyName with empty string is never equivalent to undefined sanitizedCompanyName. That is the reason for such implementation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is confusing tbh. So if both are undefined === undefined or "" === "" vayo vane ni true nai janu parne haina?

Your check right now can have following scenarios:
undefined === string | undefined
${string} === string | undedinfed,

So only thing you want to avoid is "" === string | undefined?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I want to be true is "" === undefined. Left hand side is never undefined without || undefined. QBO sends empty string when there is no value. So empty string becomes undefined with help of the logical OR operation.

})
if (match) return CustomerQueryResponseSchema.parse(match)

if (customers.length < pageSize) return
startPosition += pageSize
}
Comment thread
SandipBajracharya marked this conversation as resolved.
}
Comment thread
SandipBajracharya marked this conversation as resolved.

/**
Expand Down Expand Up @@ -892,7 +916,10 @@ export default class IntuitAPI {
includeInactive?: boolean,
): Promise<CustomerQueryResponseType>
} = this.wrapWithRetry(this._getACustomer) as any
getCustomerByEmail = this.wrapWithRetry(this._getCustomerByEmail)
// Intentionally NOT wrapped in wrapWithRetry — a transient 429 mid-walk would
// replay from page 1 and amplify rate-limit pressure. The inner customQuery
// calls already retry on 429 (same reasoning as resolveUniqueCustomerName).
getCustomerByEmail = this._getCustomerByEmail.bind(this)
getAnItem: {
(
name: string,
Expand Down
Loading
Loading