Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b07e6b8
feat(OUT-3710): add formatAssemblyInvoicePrivateNote helper
SandipBajracharya May 13, 2026
b3da507
feat(OUT-3710): add findInvoicesByDocNumberPrefix to IntuitAPI
SandipBajracharya May 13, 2026
d213359
feat(OUT-3710): add findNextAvailableDocNumber walker helper
SandipBajracharya May 13, 2026
40a5052
feat(OUT-3710): require PrivateNote on QBInvoiceCreatePayloadSchema
SandipBajracharya May 13, 2026
24d3ce1
feat(OUT-3710): walk DocNumber + stamp PrivateNote on invoice create
SandipBajracharya May 13, 2026
694cb93
feat(OUT-3710): scoped partial unique index on qb_sync_logs
SandipBajracharya May 13, 2026
0f0a081
feat(OUT-3710): migration — scoped unique index on qb_sync_logs
SandipBajracharya May 13, 2026
5b1296f
feat(OUT-3710): atomic claimWebhookEvent via partial unique index
SandipBajracharya May 13, 2026
e8584df
test(OUT-3710): atomic claimWebhookEvent + scoped partial-index behavior
SandipBajracharya May 13, 2026
f1f6eea
chore(OUT-3710): lint/format
SandipBajracharya May 13, 2026
078b9e6
fix(OUT-3710): apply final-review polish
SandipBajracharya May 13, 2026
b75657d
refactor(OUT-3710): narrow payment scope to event_type='succeeded'
SandipBajracharya May 14, 2026
bce488c
feat(OUT-3710): add nullable qb_doc_number column to qb_invoice_sync
SandipBajracharya May 14, 2026
96be927
chore(OUT-3710): drop superseded oneshot-index migration
SandipBajracharya May 14, 2026
df5ffbb
feat(OUT-3710): Sentry on walker exhaustion + populate qb_doc_number
SandipBajracharya May 14, 2026
71d58d5
fix(OUT-3710): wire payment-succeeded flow to qb_doc_number
SandipBajracharya May 14, 2026
01dd7f7
chore(OUT-3720): update query to add column if not exist
SandipBajracharya May 14, 2026
673b990
fix(OUT-3710): isQBODuplicateDocNumberError reads errors[].code
SandipBajracharya May 15, 2026
89b62b5
test(OUT-3710): align exhaustion test with MAX_SUFFIX_ATTEMPTS constant
SandipBajracharya May 15, 2026
da60bab
fix(OUT-3710): align with OUT-3543's tightened envelope schemas
SandipBajracharya May 15, 2026
bd9d5f4
chore(OUT-3710): fix lint
SandipBajracharya May 15, 2026
e836e62
fix(OUT-3710): include SyncToken while fetching invoices
SandipBajracharya May 15, 2026
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
91 changes: 84 additions & 7 deletions src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { BaseService } from '@/app/api/core/services/base.service'
import { InvoiceStatus, SyncableEntity } from '@/app/api/core/types/invoice'
import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log'
import { CustomerService } from '@/app/api/quickbooks/customer/customer.service'
import {
findNextAvailableDocNumber,
formatAssemblyInvoicePrivateNote,
isQBODuplicateDocNumberError,
} from '@/app/api/quickbooks/invoice/invoice.utils'
import { PaymentService } from '@/app/api/quickbooks/payment/payment.service'
import {
ProductService,
Expand Down Expand Up @@ -47,7 +52,7 @@ import { and, eq, isNull } from 'drizzle-orm'
import { convert } from 'html-to-text'
import httpStatus from 'http-status'
import { z } from 'zod'
import { addSyncBreadcrumb } from '@/utils/sentry'
import { addSyncBreadcrumb, captureSyncError } from '@/utils/sentry'
import { replaceSpecialCharsForQB, truncateForQB } from '@/utils/string'
import { AccountTypeObj } from '@/constant/qbConnection'

Expand Down Expand Up @@ -542,6 +547,39 @@ export class InvoiceService extends BaseService {
return { value: serviceItemRef }
}

/**
* Pre-flights QBO for invoices whose DocNumber starts with the Assembly
* invoice number and returns the lowest free slot (`<n>`, `<n>-1`, …).
* Used by webhookInvoiceCreated to dodge 6240 collisions when a customer
* has already created an invoice with the same DocNumber in QBO manually.
*/
private async resolveAvailableDocNumber(
intuitApi: IntuitAPI,
assemblyInvoiceNumber: string,
): Promise<string> {
const existing = await intuitApi.findInvoicesByDocNumberPrefix(
assemblyInvoiceNumber,
)
const taken = new Set(existing.map((inv) => inv.DocNumber))
Comment thread
SandipBajracharya marked this conversation as resolved.
try {
return findNextAvailableDocNumber(assemblyInvoiceNumber, taken)
} catch (err) {
// Exhaustion / length-limit throws aren't recoverable by resync —
// surface to Sentry so engineering sees them. Re-throw so the
// existing FAILED sync_log path still records the row.
captureSyncError(
err,
{ area: 'docnumber-walk-unresolvable' },
{
portalId: this.user.workspaceId,
assemblyInvoiceNumber,
takenCount: taken.size,
},
)
throw err
}
}

/**
* This function is executed when invoice.created event is triggered
* Handles the invoice creation in QuickBooks
Expand Down Expand Up @@ -731,12 +769,30 @@ export class InvoiceService extends BaseService {
// 5. create invoice in QB
const customerRefValue: string =
customer?.Id || existingCustomer?.qbCustomerId
const qbInvoicePayload = {

// Resolve a DocNumber that won't collide in QBO. Pre-flight a prefix
// query, pick the lowest free slot (`<n>`, `<n>-1`, `<n>-2`, …). On 6240
// race (customer manually created the slot we picked between our query
// and our create), re-walk once and retry. After that, throw and let
// resync handle it.
const assemblyInvoiceNumber = invoiceResource.number
let docNumber = await this.resolveAvailableDocNumber(
intuitApiService,
assemblyInvoiceNumber,
)

// To add customer bill email in Invoice. Docs:
// https://help.developer.intuit.com/s/question/0D50f00005E4I5nCAF/customer-email-not-showing-on-invoice
const billEmailAddress =
customer?.PrimaryEmailAddr?.Address || existingCustomer?.email

const buildPayload = (resolvedDocNumber: string) => ({
Line: lineItems,
CustomerRef: {
value: customerRefValue,
},
DocNumber: invoiceResource.number, // copilot invoice number as DocNumber
DocNumber: resolvedDocNumber,
PrivateNote: formatAssemblyInvoicePrivateNote(assemblyInvoiceNumber),
// include tax and dates
TxnTaxDetail: {
TotalTax: totalTax,
Expand All @@ -748,20 +804,40 @@ export class InvoiceService extends BaseService {
DueDate: dayjs(invoiceResource.dueDate).format('YYYY-MM-DD'), // the date format for due date follows XML Schema standard (YYYY-MM-DD). For more info: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/invoice#the-invoice-object
}),
BillEmail: {
Address: customer?.PrimaryEmailAddr?.Address || existingCustomer?.email, // To add customer bill email in Invoice. Docs: https://help.developer.intuit.com/s/question/0D50f00005E4I5nCAF/customer-email-not-showing-on-invoice
Address: billEmailAddress,
},
}
})

// 6. create invoice in QB
addSyncBreadcrumb('Creating invoice in QBO', {
invoiceNumber: invoiceResource.number,
invoiceNumber: assemblyInvoiceNumber,
docNumber,
})
const invoiceRes = await intuitApiService.createInvoice(qbInvoicePayload)

let invoiceRes
try {
invoiceRes = await intuitApiService.createInvoice(buildPayload(docNumber))
} catch (err) {
if (!isQBODuplicateDocNumberError(err)) throw err
console.info(
`InvoiceService#webhookInvoiceCreated | 6240 on DocNumber=${docNumber}; re-walking once`,
)
docNumber = await this.resolveAvailableDocNumber(
intuitApiService,
assemblyInvoiceNumber,
)
addSyncBreadcrumb('Retrying invoice creation in QBO after 6240', {
invoiceNumber: assemblyInvoiceNumber,
docNumber,
})
invoiceRes = await intuitApiService.createInvoice(buildPayload(docNumber))
}

const invoicePayload = {
portalId: this.user.workspaceId,
invoiceNumber: invoiceResource.number,
qbInvoiceId: invoiceRes.Invoice.Id,
qbDocNumber: docNumber,
qbSyncToken: invoiceRes.Invoice.SyncToken,
recipientId: recipientInfo.recipientId,
customerId: existingCustomerMapId, // foreign key to customer mapping
Expand Down Expand Up @@ -1367,6 +1443,7 @@ export class InvoiceService extends BaseService {
portalId: this.user.workspaceId,
invoiceNumber,
qbInvoiceId: qbInvoice.Id,
qbDocNumber: invoiceNumber,
qbSyncToken: qbInvoice.SyncToken,
recipientId: recipientInfo.recipientId,
customerId: customerMapId,
Expand Down
86 changes: 86 additions & 0 deletions src/app/api/quickbooks/invoice/invoice.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export const formatAssemblyInvoicePrivateNote = (
invoiceNumber: string,
): string => `Assembly invoice: ${invoiceNumber}`

const QBO_DOCNUMBER_MAX_LENGTH = 21
export const MAX_SUFFIX_ATTEMPTS = 10

/**
* Given the Assembly invoice number and a set of DocNumbers already taken in
* the target QBO realm, return the next available DocNumber in the sequence
* `<base>`, `<base>-1`, `<base>-2`, … Only exact-match candidates count as
* "taken" — unrelated DocNumbers that merely share the base prefix (returned
* over-broadly by QBO's LIKE query) are ignored.
*
* Throws if the candidate exceeds QBO's 21-char DocNumber limit, or if no
* free slot is found within MAX_SUFFIX_ATTEMPTS iterations.
*/
export const findNextAvailableDocNumber = (
base: string,
taken: ReadonlySet<string>,
): string => {
if (base.length > QBO_DOCNUMBER_MAX_LENGTH) {
throw new Error(
`DocNumber "${base}" exceeds 21 char limit; QBO will reject.`,
)
}
if (!taken.has(base)) return base
for (let n = 1; n <= MAX_SUFFIX_ATTEMPTS; n++) {
const candidate = `${base}-${n}`
if (candidate.length > QBO_DOCNUMBER_MAX_LENGTH) {
throw new Error(
`DocNumber "${candidate}" exceeds 21 char limit; cannot suffix further.`,
)
}
if (!taken.has(candidate)) return candidate
}
throw new Error(
`findNextAvailableDocNumber: no available DocNumber for "${base}" after ${MAX_SUFFIX_ATTEMPTS} attempts.`,
)
}

/**
* 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.
*/
export const isQBODuplicateDocNumberError = (err: unknown): boolean => {
if (!err || typeof err !== 'object' || Array.isArray(err)) return false
const e = err as {
status?: string | number
code?: string | number
message?: string
errors?: unknown
}
if (Array.isArray(e.errors)) {
for (const item of e.errors) {
if (!item || typeof item !== 'object') continue
const fault = item as {
code?: string | number
Detail?: string
Message?: string
}
if (fault.code === 6240 || fault.code === '6240') return true
if (
/6240|Duplicate Document Number/i.test(fault.Detail ?? '') ||
/6240|Duplicate Document Number/i.test(fault.Message ?? '')
) {
return true
}
}
}
if (e.status === 6240 || e.status === '6240') return true
if (e.code === 6240 || e.code === '6240') return true
return /6240|Duplicate Document Number/i.test(e.message ?? '')
}
Comment thread
SandipBajracharya marked this conversation as resolved.
45 changes: 29 additions & 16 deletions src/app/api/quickbooks/payment/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
QBPaymentUpdateSchema,
QBPaymentUpdateSchemaType,
} from '@/db/schema/qbPaymentSync'
import { InvoiceResponse, WhereClause } from '@/type/common'
import { WhereClause } from '@/type/common'
import {
QBPaymentCreatePayloadSchema,
QBPaymentCreatePayloadType,
Expand Down Expand Up @@ -149,11 +149,17 @@ export class PaymentService extends BaseService {
}
}

async createExpenseForAbsorbedFees(
payload: QBPurchaseCreatePayloadType,
intuitApi: IntuitAPI,
id: string,
) {
async createExpenseForAbsorbedFees({
payload,
invoiceNumber,
intuitApi,
id,
}: {
payload: QBPurchaseCreatePayloadType
invoiceNumber: string
intuitApi: IntuitAPI
id: string
}) {
const parsedPayload = QBPurchaseCreatePayloadSchema.parse(payload)

addSyncBreadcrumb('Creating expense for absorbed fees')
Expand All @@ -168,7 +174,7 @@ export class PaymentService extends BaseService {
id,
{
qbInvoiceId: res.Purchase.Id,
invoiceNumber: payload.DocNumber,
invoiceNumber,
},
EventType.SUCCEEDED,
EntityType.PAYMENT,
Expand All @@ -191,11 +197,17 @@ export class PaymentService extends BaseService {
}
}

async webhookPaymentSucceeded(
parsedPaymentSucceedResource: PaymentSucceededResponseType,
qbTokenInfo: IntuitAPITokensType,
invoice: InvoiceResponse | undefined,
): Promise<void> {
async webhookPaymentSucceeded({
parsedPaymentSucceedResource,
qbTokenInfo,
qbDocNumber,
invoiceNumber,
}: {
parsedPaymentSucceedResource: PaymentSucceededResponseType
qbTokenInfo: IntuitAPITokensType
qbDocNumber: string
invoiceNumber: string
}): Promise<void> {
const paymentResource = parsedPaymentSucceedResource.data
addSyncBreadcrumb('Payment succeeded flow started', {
paymentId: paymentResource.id,
Expand Down Expand Up @@ -224,7 +236,7 @@ export class PaymentService extends BaseService {
AccountRef: {
value: z.string().parse(assetAccountRef),
},
DocNumber: invoice?.number || '',
DocNumber: qbDocNumber,
TxnDate: dayjs(paymentResource.createdAt).format('YYYY-MM-DD'), // the date format for due date follows XML Schema standard (YYYY-MM-DD). For more info: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/purchase#the-purchase-object
Line: [
{
Expand All @@ -238,11 +250,12 @@ export class PaymentService extends BaseService {
},
],
}
await this.createExpenseForAbsorbedFees(
await this.createExpenseForAbsorbedFees({
payload,
invoiceNumber,
intuitApi,
parsedPaymentSucceedResource.data.id,
)
id: parsedPaymentSucceedResource.data.id,
})
}

private async logSync(
Expand Down
28 changes: 23 additions & 5 deletions src/app/api/quickbooks/sync/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,23 @@ export class SyncService extends BaseService {
message: 'syncService#processPaymentSucceededSync | records: ',
obj: record,
})

// check if invoice exists in qbInvoiceSync table
if (!record.invoiceNumber) {
throw new Error(
`Invoice number is empty for invoice id: ${record.copilotId}`,
)
}

const invoiceSync = await this.invoiceService.getInvoiceByNumber(
record.invoiceNumber,
)
if (!invoiceSync) {
throw new Error(
`No invoice found in invoice sync table for invoice id: ${record.copilotId}`,
)
}

const intuitApi = new IntuitAPI(qbTokenInfo)
const tokenService = new TokenService(this.user)
const assetAccountRef = await tokenService.checkAndUpdateAccountStatus(
Expand All @@ -260,7 +277,7 @@ export class SyncService extends BaseService {
AccountRef: {
value: z.string().parse(assetAccountRef),
},
DocNumber: record.invoiceNumber || '',
DocNumber: invoiceSync.qbDocNumber ?? record.invoiceNumber,
TxnDate: dayjs(record.createdAt).format('YYYY-MM-DD'), // the date format for due date follows XML Schema standard (YYYY-MM-DD). For more info: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/purchase#the-purchase-object
Line: [
{
Expand All @@ -275,11 +292,12 @@ export class SyncService extends BaseService {
],
}
const paymentService = new PaymentService(this.user)
await paymentService.createExpenseForAbsorbedFees(
expensePayload,
await paymentService.createExpenseForAbsorbedFees({
payload: expensePayload,
invoiceNumber: record.invoiceNumber,
intuitApi,
record.copilotId,
)
id: record.copilotId,
})
} catch (error: unknown) {
CustomLogger.error({
message: 'SyncService#processPaymentSucceededSync',
Expand Down
Loading
Loading