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
67 changes: 56 additions & 11 deletions src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,17 @@ export class InvoiceService extends BaseService {
returningFields?: (keyof typeof QBInvoiceSync)[],
) {
const parsedInsertPayload = QBInvoiceCreateSchema.parse(payload)
const query = this.db.insert(QBInvoiceSync).values(parsedInsertPayload)
// Concurrent webhook deliveries for the same invoice can race past the
// app-level existence checks; the partial unique index on
// (portal_id, invoice_number) WHERE deleted_at IS NULL is the durable
// guard. Swallow conflicts here so the loser of the race no-ops.
const query = this.db
.insert(QBInvoiceSync)
.values(parsedInsertPayload)
.onConflictDoNothing({
target: [QBInvoiceSync.portalId, QBInvoiceSync.invoiceNumber],
where: isNull(QBInvoiceSync.deletedAt),
})

const [invoiceSync] = returningFields?.length
? await query.returning(
Expand Down Expand Up @@ -775,7 +785,20 @@ export class InvoiceService extends BaseService {
customerId: existingCustomerMapId, // foreign key to customer mapping
status: invoiceResource.status,
}
await this.createQBInvoice(invoicePayload)
const inserted = await this.createQBInvoice(invoicePayload, ['id'])

// If onConflictDoNothing skipped the insert, a concurrent delivery won
// the race. Skip logSync (the winner already wrote the CREATED log;
// overwriting it would point quickbooks_id at this losing webhook's
// orphaned QBO invoice) and skip the paid-path payment creation.
// Note: the duplicate QBO invoice from createInvoice above is the
// dual-create issue tracked separately in OUT-3655.
if (!inserted) {
console.info(
'InvoiceService#webhookInvoiceCreated | Mapping already exists (race loss), skipping logSync and payment',
)
return
}

// update/ create the record in sync log table
const totalWithTax = actualTotalAmount + totalTax
Expand Down Expand Up @@ -1337,6 +1360,18 @@ export class InvoiceService extends BaseService {
return null
}

// 2. Re-check the local mapping. A concurrent webhook delivery may have
// inserted the row while we were fetching from QBO; short-circuit to
// avoid wasted customer/mapping work. The partial unique index closes
// the remaining window between this check and the INSERT below.
const alreadyMapped = await this.getInvoiceByNumber(invoiceNumber)
if (alreadyMapped) {
console.info(
'InvoiceService#findOrMapInvoiceFromQBO | Mapping already exists, skipping',
)
return alreadyMapped
}

// 3. Resolve customer mapping (reuse existing pattern from webhookInvoiceCreated)
const customerService = new CustomerService(this.user)
const { recipientInfo, companyInfo } =
Expand Down Expand Up @@ -1369,15 +1404,25 @@ export class InvoiceService extends BaseService {
}

// 4. Create the qb_invoice_sync mapping row
await this.createQBInvoice({
portalId: this.user.workspaceId,
invoiceNumber,
qbInvoiceId: qbInvoice.Id,
qbSyncToken: qbInvoice.SyncToken,
recipientId: recipientInfo.recipientId,
customerId: customerMapId,
status,
})
const inserted = await this.createQBInvoice(
{
portalId: this.user.workspaceId,
invoiceNumber,
qbInvoiceId: qbInvoice.Id,
qbSyncToken: qbInvoice.SyncToken,
recipientId: recipientInfo.recipientId,
customerId: customerMapId,
status,
},
['id'],
)

// If onConflictDoNothing skipped the insert, a concurrent delivery won
// the race. Skip re-logging (the winner already wrote the sync log) and
// return the existing mapping.
if (!inserted) {
return await this.getInvoiceByNumber(invoiceNumber)
}

// 5. Create the sync log entry
await this.logSync(
Expand Down
6 changes: 6 additions & 0 deletions src/app/api/quickbooks/syncLog/syncLog.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,14 @@ export class SyncLogService extends BaseService {
eventType: EventType
entityType: EntityType
}) {
// Excludes soft-deleted rows so updateOrCreateQBSyncLog can't accidentally
// revive a previously soft-deleted log by updating it in place.
const conditions = [
eq(QBSyncLog.portalId, this.user.workspaceId),
eq(QBSyncLog.copilotId, copilotId),
eq(QBSyncLog.eventType, eventType),
eq(QBSyncLog.entityType, entityType),
isNull(QBSyncLog.deletedAt),
]

const query = this.db.query.QBSyncLog.findFirst({
Expand All @@ -107,9 +110,12 @@ export class SyncLogService extends BaseService {
let existingLog

if (conditions) {
// Exclude soft-deleted rows so a previously soft-deleted log isn't
// revived by an in-place update.
const sqlConditions = and(
...[conditions],
eq(QBSyncLog.entityType, payload.entityType),
isNull(QBSyncLog.deletedAt),
) as WhereClause
existingLog = await this.getOne(sqlConditions)
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE UNIQUE INDEX "uq_qb_invoice_sync_portal_id_invoice_number_active_idx" ON "qb_invoice_sync" USING btree ("portal_id","invoice_number") WHERE "qb_invoice_sync"."deleted_at" is null;
Loading
Loading