From b07e6b85cde4def68a5b6b4a9be17c4c29ccbe3c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 16:29:32 +0545 Subject: [PATCH 01/22] feat(OUT-3710): add formatAssemblyInvoicePrivateNote helper Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/quickbooks/invoice/invoice.utils.ts | 3 +++ .../api/quickbooks/invoice/invoice.utils.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/app/api/quickbooks/invoice/invoice.utils.ts create mode 100644 test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts diff --git a/src/app/api/quickbooks/invoice/invoice.utils.ts b/src/app/api/quickbooks/invoice/invoice.utils.ts new file mode 100644 index 00000000..1c91aa4e --- /dev/null +++ b/src/app/api/quickbooks/invoice/invoice.utils.ts @@ -0,0 +1,3 @@ +export const formatAssemblyInvoicePrivateNote = ( + invoiceNumber: string, +): string => `Assembly invoice: ${invoiceNumber}` diff --git a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts new file mode 100644 index 00000000..3f597185 --- /dev/null +++ b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { formatAssemblyInvoicePrivateNote } from '@/app/api/quickbooks/invoice/invoice.utils' + +describe('formatAssemblyInvoicePrivateNote', () => { + it('formats an invoice number into the canonical PrivateNote string', () => { + expect(formatAssemblyInvoicePrivateNote('MFBZU6WM-00002')).toBe( + 'Assembly invoice: MFBZU6WM-00002', + ) + }) + + it('passes through arbitrary alphanumerics with hyphens unchanged', () => { + expect(formatAssemblyInvoicePrivateNote('ABC-12345')).toBe( + 'Assembly invoice: ABC-12345', + ) + }) +}) From b3da507a3cfb31291a89e7a016126f0f48a6711e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 16:33:42 +0545 Subject: [PATCH 02/22] feat(OUT-3710): add findInvoicesByDocNumberPrefix to IntuitAPI Mirrors the no-retry registration of getInvoice (bind, not wrapWithRetry) since the underlying customQuery is already retry-wrapped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils/intuitAPI.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index c637f316..0756ef3f 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -704,6 +704,35 @@ export default class IntuitAPI { return SingleIdAndTokenResponseSchema.parse(envelope.Invoice[0]) } + /** + * Returns all QBO invoices whose DocNumber starts with `prefix`. Used by + * findNextAvailableDocNumber to detect collisions and pick the next free + * suffix before createInvoice. Caps at maxresults=100; if a single prefix + * has more matches, the caller falls back to catch-6240 retry semantics. + */ + async _findInvoicesByDocNumberPrefix( + prefix: string, + ): Promise> { + // LIKE-wildcard chars in user input would broaden the match. Assembly + // invoice numbers don't contain '%' or '_', but escape defensively. + const escapedPrefix = escapeForQBQuery(prefix) + .replace(/%/g, '\\%') + .replace(/_/g, '\\_') + const query = `select Id, DocNumber from Invoice where DocNumber LIKE '${escapedPrefix}%' maxresults 100` + const response = await this.customQuery(query) + if (!response) { + throw new APIError( + httpStatus.BAD_REQUEST, + 'IntuitAPI#findInvoicesByDocNumberPrefix | message = no response', + ) + } + if (!response.Invoice) return [] + return response.Invoice.map((inv: { Id: string; DocNumber?: string }) => ({ + Id: inv.Id, + DocNumber: inv.DocNumber ?? '', + })) + } + async _voidInvoice( payload: QBDestructiveInvoicePayloadSchema, ): Promise { @@ -952,6 +981,7 @@ export default class IntuitAPI { itemFullUpdate = this.wrapWithRetry(this._itemFullUpdate) createPayment = this.wrapWithRetry(this._createPayment) getInvoice = this._getInvoice.bind(this) + findInvoicesByDocNumberPrefix = this._findInvoicesByDocNumberPrefix.bind(this) voidInvoice = this.wrapWithRetry(this._voidInvoice) deleteInvoice = this.wrapWithRetry(this._deleteInvoice) getAnAccount: GetAnAccountOverloads = this._getAnAccount.bind( From d213359b27cae7338fa332c43bbbd2e90d7eaee7 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 16:42:22 +0545 Subject: [PATCH 03/22] feat(OUT-3710): add findNextAvailableDocNumber walker helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks the sequence , -1, …, -99 and returns the first slot not in the taken set. Hoists the 21-char precondition above the branching so it fires unconditionally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/quickbooks/invoice/invoice.utils.ts | 37 +++++++++++ .../quickbooks/invoice/invoice.utils.test.ts | 66 ++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/app/api/quickbooks/invoice/invoice.utils.ts b/src/app/api/quickbooks/invoice/invoice.utils.ts index 1c91aa4e..2e2617d8 100644 --- a/src/app/api/quickbooks/invoice/invoice.utils.ts +++ b/src/app/api/quickbooks/invoice/invoice.utils.ts @@ -1,3 +1,40 @@ export const formatAssemblyInvoicePrivateNote = ( invoiceNumber: string, ): string => `Assembly invoice: ${invoiceNumber}` + +const QBO_DOCNUMBER_MAX_LENGTH = 21 +const MAX_SUFFIX_ATTEMPTS = 99 + +/** + * 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 + * ``, `-1`, `-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 => { + 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.`, + ) +} diff --git a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts index 3f597185..02120d60 100644 --- a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts +++ b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { formatAssemblyInvoicePrivateNote } from '@/app/api/quickbooks/invoice/invoice.utils' +import { + findNextAvailableDocNumber, + formatAssemblyInvoicePrivateNote, +} from '@/app/api/quickbooks/invoice/invoice.utils' describe('formatAssemblyInvoicePrivateNote', () => { it('formats an invoice number into the canonical PrivateNote string', () => { @@ -14,3 +17,64 @@ describe('formatAssemblyInvoicePrivateNote', () => { ) }) }) + +describe('findNextAvailableDocNumber', () => { + it('returns the base DocNumber when no collisions exist', () => { + const taken = new Set() + expect(findNextAvailableDocNumber('MFBZU6WM-00002', taken)).toBe( + 'MFBZU6WM-00002', + ) + }) + + it('returns -1 when the base is taken', () => { + const taken = new Set(['MFBZU6WM-00002']) + expect(findNextAvailableDocNumber('MFBZU6WM-00002', taken)).toBe( + 'MFBZU6WM-00002-1', + ) + }) + + it('walks past taken suffixes to the next free slot', () => { + const taken = new Set([ + 'MFBZU6WM-00002', + 'MFBZU6WM-00002-1', + 'MFBZU6WM-00002-2', + ]) + expect(findNextAvailableDocNumber('MFBZU6WM-00002', taken)).toBe( + 'MFBZU6WM-00002-3', + ) + }) + + it('ignores unrelated DocNumbers that happen to share the prefix', () => { + const taken = new Set([ + 'MFBZU6WM-00002SOMETHING', + 'MFBZU6WM-00002-1-EXTRA', + ]) + expect(findNextAvailableDocNumber('MFBZU6WM-00002', taken)).toBe( + 'MFBZU6WM-00002', + ) + }) + + it('throws when no suffix fits within the 21-char DocNumber limit', () => { + const longBase = 'ABCDEFGHIJKLMNOPQRST' + const taken = new Set([longBase]) + expect(() => findNextAvailableDocNumber(longBase, taken)).toThrow( + /exceeds 21/, + ) + }) + + it('throws when the base itself exceeds 21 chars and is not taken', () => { + const longBase = 'A'.repeat(22) + expect(() => findNextAvailableDocNumber(longBase, new Set())).toThrow( + /exceeds 21/, + ) + }) + + it('throws after exhausting 99 suffix slots', () => { + const base = 'TEST-001' + const taken = new Set([base]) + for (let n = 1; n <= 99; n++) taken.add(`${base}-${n}`) + expect(() => findNextAvailableDocNumber(base, taken)).toThrow( + /no available DocNumber/, + ) + }) +}) From 40a5052c470d73ce13af8849c2da75e5c522c6a5 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 16:43:34 +0545 Subject: [PATCH 04/22] feat(OUT-3710): require PrivateNote on QBInvoiceCreatePayloadSchema Leaves tsc broken at invoice.service.ts:759 until Task 5 lands the walker + PrivateNote wiring at the call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/type/dto/intuitAPI.dto.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 0c67256c..7fbcdd34 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -37,6 +37,7 @@ export const QBInvoiceCreatePayloadSchema = z.object({ CustomerRef: z.object({ value: z.string(), }), + PrivateNote: z.string(), }) export type QBInvoiceCreatePayloadType = z.infer< From 24d3ce1b2c7f0057c7d2bb4bc96b44c693e18d94 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 16:53:08 +0545 Subject: [PATCH 05/22] feat(OUT-3710): walk DocNumber + stamp PrivateNote on invoice create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-flight QBO with DocNumber LIKE '%' to detect collisions, walk to the lowest free slot in the sequence , -1, -2, …. Stamp PrivateNote: "Assembly invoice: " for cross-reference. On QBO 6240 race, re-walk once and retry; second 6240 escapes to withErrorHandler and resync. isQBODuplicateDocNumberError checks .status, .code, and a regex on .message — JSDoc explains that today only the regex path fires due to a pre-existing array-access bug in intuitAPI.ts:145. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/quickbooks/invoice/invoice.service.ts | 71 +++++++++++++++++-- .../api/quickbooks/invoice/invoice.utils.ts | 22 ++++++ .../quickbooks/invoice/invoice.utils.test.ts | 31 ++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index bfd073bd..4a425cc3 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -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, @@ -542,6 +547,23 @@ 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 (``, `-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 { + const existing = await intuitApi.findInvoicesByDocNumberPrefix( + assemblyInvoiceNumber, + ) + const taken = new Set(existing.map((inv) => inv.DocNumber)) + return findNextAvailableDocNumber(assemblyInvoiceNumber, taken) + } + /** * This function is executed when invoice.created event is triggered * Handles the invoice creation in QuickBooks @@ -731,12 +753,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 (``, `-1`, `-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, @@ -748,15 +788,34 @@ 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, diff --git a/src/app/api/quickbooks/invoice/invoice.utils.ts b/src/app/api/quickbooks/invoice/invoice.utils.ts index 2e2617d8..b6c3de8a 100644 --- a/src/app/api/quickbooks/invoice/invoice.utils.ts +++ b/src/app/api/quickbooks/invoice/invoice.utils.ts @@ -38,3 +38,25 @@ export const findNextAvailableDocNumber = ( `findNextAvailableDocNumber: no available DocNumber for "${base}" after ${MAX_SUFFIX_ATTEMPTS} attempts.`, ) } + +/** + * Recognizes QBO Error 6240 "Duplicate Document Number" across error shapes. + * + * The `.status`/`.code` branches are forward-compatible: today `intuitAPI.ts` + * reads `Fault.Error?.code` as if it were an object (it's actually an array), + * so APIError lands with status=400, not 6240. The live safety net is the + * regex over `.message`. When the array-access is corrected the structured + * branches will start firing too. + */ +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 + } + if (e.status === 6240 || e.status === '6240') return true + if (e.code === 6240 || e.code === '6240') return true + const message = e.message ?? '' + return /6240|Duplicate Document Number/i.test(message) +} diff --git a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts index 02120d60..efb7401d 100644 --- a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts +++ b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { findNextAvailableDocNumber, formatAssemblyInvoicePrivateNote, + isQBODuplicateDocNumberError, } from '@/app/api/quickbooks/invoice/invoice.utils' describe('formatAssemblyInvoicePrivateNote', () => { @@ -78,3 +79,33 @@ describe('findNextAvailableDocNumber', () => { ) }) }) + +describe('isQBODuplicateDocNumberError', () => { + it('matches numeric 6240 code', () => { + expect(isQBODuplicateDocNumberError({ code: 6240 })).toBe(true) + }) + it('matches stringified 6240 code', () => { + expect(isQBODuplicateDocNumberError({ code: '6240' })).toBe(true) + }) + it('matches numeric 6240 status (APIError shape from createInvoice)', () => { + expect(isQBODuplicateDocNumberError({ status: 6240 })).toBe(true) + }) + it('matches stringified 6240 status', () => { + expect(isQBODuplicateDocNumberError({ status: '6240' })).toBe(true) + }) + it('matches the duplicate-doc-number message', () => { + expect( + isQBODuplicateDocNumberError({ + message: 'Duplicate Document Number Error', + }), + ).toBe(true) + }) + it('returns false for unrelated errors', () => { + expect(isQBODuplicateDocNumberError({ code: 5010 })).toBe(false) + expect(isQBODuplicateDocNumberError({ status: 400 })).toBe(false) + expect(isQBODuplicateDocNumberError(null)).toBe(false) + expect(isQBODuplicateDocNumberError(undefined)).toBe(false) + expect(isQBODuplicateDocNumberError('not an object')).toBe(false) + expect(isQBODuplicateDocNumberError({})).toBe(false) + }) +}) From 694cb936bea3bd2645ae112cc6af2eaef1d57da5 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 16:57:04 +0545 Subject: [PATCH 06/22] feat(OUT-3710): scoped partial unique index on qb_sync_logs Indexes (portal_id, copilot_id, entity_type, event_type) with a partial WHERE filter limited to invoice one-shot events (created/paid/voided/ deleted) plus all payment events. Excludes invoice/updated, product, and price rows where multi-fire is legitimate. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/schema/qbSyncLogs.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/db/schema/qbSyncLogs.ts b/src/db/schema/qbSyncLogs.ts index 85529727..5bbd5527 100644 --- a/src/db/schema/qbSyncLogs.ts +++ b/src/db/schema/qbSyncLogs.ts @@ -66,6 +66,24 @@ export const QBSyncLog = table( .index('idx_qb_sync_logs_pending_reaper') .on(table.portalId, table.status, table.createdAt) .where(isNull(table.deletedAt)), + // Atomic-claim safety: prevents two sync_log rows for the same (portal, + // copilot_id, entity, event) within the slice of "one-shot" events. Pairs + // with INSERT ... ON CONFLICT DO NOTHING in claimWebhookEvent. Deliberately + // scoped to: + // - INVOICE/{created,paid,voided,deleted}: one-shot per invoice; dual-fire + // would cause customer-visible duplicate QBO invoices. + // - PAYMENT/*: one-shot per payment. + // INVOICE/updated, PRODUCT, and PRICE events are excluded because repeated + // edits / re-fires are legitimate for those entity-event combinations. + t + .uniqueIndex('uq_qb_sync_logs_oneshot_active') + .on(table.portalId, table.copilotId, table.entityType, table.eventType) + .where( + sql`${table.deletedAt} IS NULL AND ( + (${table.entityType} = 'invoice' AND ${table.eventType} IN ('created','paid','voided','deleted')) + OR ${table.entityType} = 'payment' + )`, + ), ], ) From 0f0a081b3c383b06470a76b63f9a19e9bd098cb7 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 16:57:07 +0545 Subject: [PATCH 07/22] =?UTF-8?q?feat(OUT-3710):=20migration=20=E2=80=94?= =?UTF-8?q?=20scoped=20unique=20index=20on=20qb=5Fsync=5Flogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedupe of historical duplicates is a separate manual SQL run by the operator before this migration applies (see plan doc, "Deploy procedure" section). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...t_unique_partial_index_in_qb_sync_logs.sql | 4 + .../meta/20260513111101_snapshot.json | 1107 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + 3 files changed, 1118 insertions(+) create mode 100644 src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql create mode 100644 src/db/migrations/meta/20260513111101_snapshot.json diff --git a/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql b/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql new file mode 100644 index 00000000..3dae3b54 --- /dev/null +++ b/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql @@ -0,0 +1,4 @@ +CREATE UNIQUE INDEX "uq_qb_sync_logs_oneshot_active" ON "qb_sync_logs" USING btree ("portal_id","copilot_id","entity_type","event_type") WHERE "qb_sync_logs"."deleted_at" IS NULL AND ( + ("qb_sync_logs"."entity_type" = 'invoice' AND "qb_sync_logs"."event_type" IN ('created','paid','voided','deleted')) + OR "qb_sync_logs"."entity_type" = 'payment' + ); \ No newline at end of file diff --git a/src/db/migrations/meta/20260513111101_snapshot.json b/src/db/migrations/meta/20260513111101_snapshot.json new file mode 100644 index 00000000..2e543c31 --- /dev/null +++ b/src/db/migrations/meta/20260513111101_snapshot.json @@ -0,0 +1,1107 @@ +{ + "id": "056be0dd-8db9-4f73-bd22-cb8f5a2598ce", + "prevId": "4407e100-64fd-4a94-a638-4be325ce7d5c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "copilot_unit_price": { + "name": "copilot_unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR \"qb_sync_logs\".\"entity_type\" = 'payment'\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 9b0704ce..e5c52490 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1777284208082, "tag": "20260427100328_add_unique_indexes_in_qb_sync_logs_table", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1778670661297, + "tag": "20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs", + "breakpoints": true } ] } \ No newline at end of file From 5b1296facbb38d44b255288a5ed5aed1ca7fbfb6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 17:02:07 +0545 Subject: [PATCH 08/22] feat(OUT-3710): atomic claimWebhookEvent via partial unique index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the check-then-insert TOCTOU pattern with a single atomic INSERT … ON CONFLICT (cols) WHERE DO NOTHING. The predicate uses bare column names matching the migration's index predicate verbatim so PG can recognize the implication. For rows outside the partial-index slice (invoice/updated, product, price), INSERT always succeeds and claim returns true — matching the prior non-atomic behavior where legitimate multi-fire is expected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/quickbooks/syncLog/syncLog.service.ts | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/src/app/api/quickbooks/syncLog/syncLog.service.ts b/src/app/api/quickbooks/syncLog/syncLog.service.ts index e7552a7b..143d3ecf 100644 --- a/src/app/api/quickbooks/syncLog/syncLog.service.ts +++ b/src/app/api/quickbooks/syncLog/syncLog.service.ts @@ -18,7 +18,7 @@ import { import { WhereClause } from '@/type/common' import { orderMap } from '@/utils/drizzle' import dayjs from 'dayjs' -import { and, eq, isNull, lt } from 'drizzle-orm' +import { and, eq, isNull, lt, sql } from 'drizzle-orm' import { json2csv } from 'json-2-csv' export const STALE_PENDING_THRESHOLD_MINUTES = 15 @@ -141,19 +141,14 @@ export class SyncLogService extends BaseService { } /** - * Atomic-ish idempotency claim for webhook entry. Returns `claimed: true` if - * we successfully wrote a new PENDING row for this (portal, copilot, entity, - * event) tuple, or `claimed: false` if a row already exists for that tuple - * — meaning another delivery has handled or is handling it. - * - * Without a unique constraint on `qb_sync_logs` (descoped due to historical - * production duplicates), the read-then-insert has a sub-millisecond TOCTOU - * window. In practice this closes the dominant `invoice.created` + - * `invoice.updated` race because the existing `sleep(10000)` already - * serialises those events. - * - * Stale claims (PENDING older than `STALE_PENDING_THRESHOLD_MINUTES`) are - * recovered by `flipStalePendingToFailed` during the next resync cycle. + * Atomic idempotency claim via the partial unique index + * `uq_qb_sync_logs_oneshot_active` (covers active invoice one-shot events + * and all payment events). For rows in that slice, ON CONFLICT DO NOTHING + * yields no row when another worker has already claimed the tuple, so + * `claimed: false` is returned. For rows outside the slice + * (INVOICE/UPDATED, PRODUCT, PRICE), the partial index does not apply and + * INSERT always succeeds — preserving prior behavior for legitimate re-fires. + * Stale PENDING claims are recovered by `flipStalePendingToFailed`. */ async claimWebhookEvent({ copilotId, @@ -166,24 +161,31 @@ export class SyncLogService extends BaseService { eventType: EventType invoiceNumber?: string }): Promise<{ claimed: boolean }> { - const existing = await this.getOneByCopilotIdAndEventType({ - copilotId, - eventType, - entityType, - }) - if (existing) { - return { claimed: false } - } + const inserted = await this.db + .insert(QBSyncLog) + .values({ + portalId: this.user.workspaceId, + copilotId, + entityType, + eventType, + status: LogStatus.PENDING, + invoiceNumber, + }) + .onConflictDoNothing({ + target: [ + QBSyncLog.portalId, + QBSyncLog.copilotId, + QBSyncLog.entityType, + QBSyncLog.eventType, + ], + where: sql`deleted_at IS NULL AND ( + (entity_type = 'invoice' AND event_type IN ('created','paid','voided','deleted')) + OR entity_type = 'payment' + )`, + }) + .returning({ id: QBSyncLog.id }) - await this.createQBSyncLog({ - portalId: this.user.workspaceId, - copilotId, - entityType, - eventType, - status: LogStatus.PENDING, - invoiceNumber, - }) - return { claimed: true } + return { claimed: inserted.length > 0 } } /** From e8584dfb0e40c808ebb9935ed4259542e3c0640c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 17:04:59 +0545 Subject: [PATCH 09/22] test(OUT-3710): atomic claimWebhookEvent + scoped partial-index behavior Six scenarios: duplicate invoice/created blocked, created vs paid both claimed, soft-delete unblocks re-claim, invoice/updated multi-fire allowed, product/updated multi-fire allowed, duplicate payment/ succeeded blocked. Runtime confirms the ON CONFLICT predicate matches the partial unique index uq_qb_sync_logs_oneshot_active. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../quickbooks/syncLog/claimAtomicity.test.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 test/integration/quickbooks/syncLog/claimAtomicity.test.ts diff --git a/test/integration/quickbooks/syncLog/claimAtomicity.test.ts b/test/integration/quickbooks/syncLog/claimAtomicity.test.ts new file mode 100644 index 00000000..a2120a1d --- /dev/null +++ b/test/integration/quickbooks/syncLog/claimAtomicity.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { and, eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' +import { EntityType, EventType } from '@/app/api/core/types/log' + +import { seedHealthyPortal, TEST_PORTAL_ID } from '@test/helpers/seed' +import { truncateAllTestTables } from '@test/helpers/testDb' + +const makeUser = () => ({ workspaceId: TEST_PORTAL_ID }) as any + +describe('claimWebhookEvent atomicity', () => { + beforeEach(async () => { + await truncateAllTestTables() + await seedHealthyPortal() + }) + + describe('invoice one-shot events (covered by partial unique index)', () => { + it('returns claimed=true for the first call and claimed=false for the duplicate (invoice/created)', async () => { + const service = new SyncLogService(makeUser()) + const args = { + copilotId: 'inv_abc', + entityType: EntityType.INVOICE, + eventType: EventType.CREATED, + invoiceNumber: 'TEST-00001', + } + const first = await service.claimWebhookEvent(args) + const second = await service.claimWebhookEvent(args) + expect(first).toEqual({ claimed: true }) + expect(second).toEqual({ claimed: false }) + + const rows = await db + .select() + .from(QBSyncLog) + .where( + and( + eq(QBSyncLog.portalId, TEST_PORTAL_ID), + eq(QBSyncLog.copilotId, 'inv_abc'), + eq(QBSyncLog.eventType, EventType.CREATED), + ), + ) + expect(rows).toHaveLength(1) + }) + + it('allows the same copilotId across different event types (created vs paid)', async () => { + const service = new SyncLogService(makeUser()) + const created = await service.claimWebhookEvent({ + copilotId: 'inv_xyz', + entityType: EntityType.INVOICE, + eventType: EventType.CREATED, + invoiceNumber: 'TEST-00002', + }) + const paid = await service.claimWebhookEvent({ + copilotId: 'inv_xyz', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + invoiceNumber: 'TEST-00002', + }) + expect(created).toEqual({ claimed: true }) + expect(paid).toEqual({ claimed: true }) + }) + + it('does not block a fresh claim after the prior row is soft-deleted', async () => { + const service = new SyncLogService(makeUser()) + const args = { + copilotId: 'inv_soft', + entityType: EntityType.INVOICE, + eventType: EventType.CREATED, + invoiceNumber: 'TEST-00003', + } + const first = await service.claimWebhookEvent(args) + expect(first).toEqual({ claimed: true }) + + await db + .update(QBSyncLog) + .set({ deletedAt: new Date() }) + .where( + and( + eq(QBSyncLog.portalId, TEST_PORTAL_ID), + eq(QBSyncLog.copilotId, 'inv_soft'), + ), + ) + + const second = await service.claimWebhookEvent(args) + expect(second).toEqual({ claimed: true }) + }) + }) + + describe('events outside the partial-index slice', () => { + it('allows multiple invoice/updated claims for the same copilotId (legitimate multi-update)', async () => { + const service = new SyncLogService(makeUser()) + const args = { + copilotId: 'inv_upd', + entityType: EntityType.INVOICE, + eventType: EventType.UPDATED, + invoiceNumber: 'TEST-00004', + } + const first = await service.claimWebhookEvent(args) + const second = await service.claimWebhookEvent(args) + expect(first).toEqual({ claimed: true }) + expect(second).toEqual({ claimed: true }) + + const rows = await db + .select() + .from(QBSyncLog) + .where( + and( + eq(QBSyncLog.portalId, TEST_PORTAL_ID), + eq(QBSyncLog.copilotId, 'inv_upd'), + eq(QBSyncLog.eventType, EventType.UPDATED), + ), + ) + expect(rows).toHaveLength(2) + }) + + it('allows multiple product/updated claims for the same copilotId', async () => { + const service = new SyncLogService(makeUser()) + const args = { + copilotId: 'prod_aaa', + entityType: EntityType.PRODUCT, + eventType: EventType.UPDATED, + } + const first = await service.claimWebhookEvent(args) + const second = await service.claimWebhookEvent(args) + expect(first).toEqual({ claimed: true }) + expect(second).toEqual({ claimed: true }) + }) + }) + + describe('payment events', () => { + it('blocks duplicate payment/succeeded claims for the same copilotId', async () => { + const service = new SyncLogService(makeUser()) + const args = { + copilotId: 'pay_001', + entityType: EntityType.PAYMENT, + eventType: EventType.SUCCEEDED, + invoiceNumber: 'TEST-00005', + } + const first = await service.claimWebhookEvent(args) + const second = await service.claimWebhookEvent(args) + expect(first).toEqual({ claimed: true }) + expect(second).toEqual({ claimed: false }) + }) + }) +}) From f1f6eea87544ea4a86d63a003974a18ccd02ab8b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 17:05:58 +0545 Subject: [PATCH 10/22] chore(OUT-3710): lint/format Co-Authored-By: Claude Opus 4.7 (1M context) --- test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts index efb7401d..46b2eba3 100644 --- a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts +++ b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts @@ -46,10 +46,7 @@ describe('findNextAvailableDocNumber', () => { }) it('ignores unrelated DocNumbers that happen to share the prefix', () => { - const taken = new Set([ - 'MFBZU6WM-00002SOMETHING', - 'MFBZU6WM-00002-1-EXTRA', - ]) + const taken = new Set(['MFBZU6WM-00002SOMETHING', 'MFBZU6WM-00002-1-EXTRA']) expect(findNextAvailableDocNumber('MFBZU6WM-00002', taken)).toBe( 'MFBZU6WM-00002', ) From 078b9e6050cc1fb7a05aff0f23472cf9ef34c0a3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 17:10:00 +0545 Subject: [PATCH 11/22] fix(OUT-3710): apply final-review polish - Cap PrivateNote at QBO's 4000-char max in the Zod schema so over-long inputs fail validation locally rather than as a remote QBO error. - Trailing newline on the migration file. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...3111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql | 2 +- src/type/dto/intuitAPI.dto.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql b/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql index 3dae3b54..33e3c2cb 100644 --- a/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql +++ b/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql @@ -1,4 +1,4 @@ CREATE UNIQUE INDEX "uq_qb_sync_logs_oneshot_active" ON "qb_sync_logs" USING btree ("portal_id","copilot_id","entity_type","event_type") WHERE "qb_sync_logs"."deleted_at" IS NULL AND ( ("qb_sync_logs"."entity_type" = 'invoice' AND "qb_sync_logs"."event_type" IN ('created','paid','voided','deleted')) OR "qb_sync_logs"."entity_type" = 'payment' - ); \ No newline at end of file + ); diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 7fbcdd34..1bcdae41 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -37,7 +37,7 @@ export const QBInvoiceCreatePayloadSchema = z.object({ CustomerRef: z.object({ value: z.string(), }), - PrivateNote: z.string(), + PrivateNote: z.string().max(4000), }) export type QBInvoiceCreatePayloadType = z.infer< From b75657d6c614d1a30ef720fc469887b18d41ddf7 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 14 May 2026 12:21:10 +0545 Subject: [PATCH 12/22] refactor(OUT-3710): narrow payment scope to event_type='succeeded' Partial unique index and atomic claim now cover payment/succeeded specifically rather than all payment event types. Today this is behaviorally equivalent (succeeded is the only payment event the codebase fires), but the narrower predicate matches the documented intent and prevents future PAYMENT/* event types from being silently deduplicated. Migration regenerated with new timestamp after the prior file was removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/quickbooks/syncLog/syncLog.service.ts | 2 +- ...t_unique_partial_index_in_qb_sync_logs.sql | 4 + .../meta/20260514063523_snapshot.json | 1107 +++++++++++++++++ src/db/migrations/meta/_journal.json | 4 +- src/db/schema/qbSyncLogs.ts | 4 +- 5 files changed, 1116 insertions(+), 5 deletions(-) create mode 100644 src/db/migrations/20260514063523_add_oneshot_unique_partial_index_in_qb_sync_logs.sql create mode 100644 src/db/migrations/meta/20260514063523_snapshot.json diff --git a/src/app/api/quickbooks/syncLog/syncLog.service.ts b/src/app/api/quickbooks/syncLog/syncLog.service.ts index 143d3ecf..2b821146 100644 --- a/src/app/api/quickbooks/syncLog/syncLog.service.ts +++ b/src/app/api/quickbooks/syncLog/syncLog.service.ts @@ -180,7 +180,7 @@ export class SyncLogService extends BaseService { ], where: sql`deleted_at IS NULL AND ( (entity_type = 'invoice' AND event_type IN ('created','paid','voided','deleted')) - OR entity_type = 'payment' + OR (entity_type = 'payment' AND event_type = 'succeeded') )`, }) .returning({ id: QBSyncLog.id }) diff --git a/src/db/migrations/20260514063523_add_oneshot_unique_partial_index_in_qb_sync_logs.sql b/src/db/migrations/20260514063523_add_oneshot_unique_partial_index_in_qb_sync_logs.sql new file mode 100644 index 00000000..8e9fd5e6 --- /dev/null +++ b/src/db/migrations/20260514063523_add_oneshot_unique_partial_index_in_qb_sync_logs.sql @@ -0,0 +1,4 @@ +CREATE UNIQUE INDEX "uq_qb_sync_logs_oneshot_active" ON "qb_sync_logs" USING btree ("portal_id","copilot_id","entity_type","event_type") WHERE "qb_sync_logs"."deleted_at" IS NULL AND ( + ("qb_sync_logs"."entity_type" = 'invoice' AND "qb_sync_logs"."event_type" IN ('created','paid','voided','deleted')) + OR ("qb_sync_logs"."entity_type" = 'payment' AND "qb_sync_logs"."event_type" = 'succeeded') + ); diff --git a/src/db/migrations/meta/20260514063523_snapshot.json b/src/db/migrations/meta/20260514063523_snapshot.json new file mode 100644 index 00000000..66599c63 --- /dev/null +++ b/src/db/migrations/meta/20260514063523_snapshot.json @@ -0,0 +1,1107 @@ +{ + "id": "6d08de54-5942-488f-b7b6-3f40f8deb3e6", + "prevId": "4407e100-64fd-4a94-a638-4be325ce7d5c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "copilot_unit_price": { + "name": "copilot_unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR (\"qb_sync_logs\".\"entity_type\" = 'payment' AND \"qb_sync_logs\".\"event_type\" = 'succeeded')\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index e5c52490..93c2de33 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -138,8 +138,8 @@ { "idx": 19, "version": "7", - "when": 1778670661297, - "tag": "20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs", + "when": 1778740523746, + "tag": "20260514063523_add_oneshot_unique_partial_index_in_qb_sync_logs", "breakpoints": true } ] diff --git a/src/db/schema/qbSyncLogs.ts b/src/db/schema/qbSyncLogs.ts index 5bbd5527..d4c55573 100644 --- a/src/db/schema/qbSyncLogs.ts +++ b/src/db/schema/qbSyncLogs.ts @@ -72,7 +72,7 @@ export const QBSyncLog = table( // scoped to: // - INVOICE/{created,paid,voided,deleted}: one-shot per invoice; dual-fire // would cause customer-visible duplicate QBO invoices. - // - PAYMENT/*: one-shot per payment. + // - PAYMENT/succeeded: one-shot per payment. // INVOICE/updated, PRODUCT, and PRICE events are excluded because repeated // edits / re-fires are legitimate for those entity-event combinations. t @@ -81,7 +81,7 @@ export const QBSyncLog = table( .where( sql`${table.deletedAt} IS NULL AND ( (${table.entityType} = 'invoice' AND ${table.eventType} IN ('created','paid','voided','deleted')) - OR ${table.entityType} = 'payment' + OR (${table.entityType} = 'payment' AND ${table.eventType} = 'succeeded') )`, ), ], From bce488cbeba3079eee080ba4b46bd5b6967534e9 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 14 May 2026 15:40:31 +0545 Subject: [PATCH 13/22] feat(OUT-3710): add nullable qb_doc_number column to qb_invoice_sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single ALTER TABLE ADD COLUMN — metadata-only, no row rewrite, no lock on large tables. Column is nullable so legacy rows aren't forced through an in-migration UPDATE. Application writes always populate the column for new invoices; legacy rows can be backfilled by a separate one-time script via getInvoice, decoupled from deploy timing. Stores the DocNumber QBO accepted. Equals invoice_number in the no- collision happy path; differs when the walker suffixed (e.g., MFBZU6WM-00002-1) to avoid a 6240 duplicate. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...b_doc_number_column_in_qb_invoice_sync.sql | 1 + .../meta/20260514095346_snapshot.json | 1113 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema/qbInvoiceSync.ts | 1 + 4 files changed, 1122 insertions(+) create mode 100644 src/db/migrations/20260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql create mode 100644 src/db/migrations/meta/20260514095346_snapshot.json diff --git a/src/db/migrations/20260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql b/src/db/migrations/20260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql new file mode 100644 index 00000000..e4817288 --- /dev/null +++ b/src/db/migrations/20260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql @@ -0,0 +1 @@ +ALTER TABLE "qb_invoice_sync" ADD COLUMN "qb_doc_number" varchar; diff --git a/src/db/migrations/meta/20260514095346_snapshot.json b/src/db/migrations/meta/20260514095346_snapshot.json new file mode 100644 index 00000000..061af686 --- /dev/null +++ b/src/db/migrations/meta/20260514095346_snapshot.json @@ -0,0 +1,1113 @@ +{ + "id": "ac41313a-2978-42ad-aa32-ea26dc31d10c", + "prevId": "6d08de54-5942-488f-b7b6-3f40f8deb3e6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_doc_number": { + "name": "qb_doc_number", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "copilot_unit_price": { + "name": "copilot_unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR (\"qb_sync_logs\".\"entity_type\" = 'payment' AND \"qb_sync_logs\".\"event_type\" = 'succeeded')\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 93c2de33..b4c13f57 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1778740523746, "tag": "20260514063523_add_oneshot_unique_partial_index_in_qb_sync_logs", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1778752426056, + "tag": "20260514095346_add_qb_doc_number_column_in_qb_invoice_sync", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/qbInvoiceSync.ts b/src/db/schema/qbInvoiceSync.ts index e5892bf4..928e801c 100644 --- a/src/db/schema/qbInvoiceSync.ts +++ b/src/db/schema/qbInvoiceSync.ts @@ -24,6 +24,7 @@ export const QBInvoiceSync = table( }), invoiceNumber: t.varchar('invoice_number').notNull(), qbInvoiceId: t.varchar('qb_invoice_id'), + qbDocNumber: t.varchar('qb_doc_number'), qbSyncToken: t.varchar('qb_sync_token', { length: 100 }), recipientId: t.uuid('recipient_id'), status: invoiceStatusEnum('status').default(InvoiceStatus.OPEN).notNull(), From 96be927a1cbd7091842d1bd7af0d79dc284d440f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 14 May 2026 15:40:41 +0545 Subject: [PATCH 14/22] chore(OUT-3710): drop superseded oneshot-index migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced by 20260514063523_… (already in c9f92e5) which narrowed the payment scope to event_type='succeeded'. The original was never applied to any database we care about. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...t_unique_partial_index_in_qb_sync_logs.sql | 4 - .../meta/20260513111101_snapshot.json | 1107 ----------------- 2 files changed, 1111 deletions(-) delete mode 100644 src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql delete mode 100644 src/db/migrations/meta/20260513111101_snapshot.json diff --git a/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql b/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql deleted file mode 100644 index 33e3c2cb..00000000 --- a/src/db/migrations/20260513111101_add_oneshot_unique_partial_index_in_qb_sync_logs.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE UNIQUE INDEX "uq_qb_sync_logs_oneshot_active" ON "qb_sync_logs" USING btree ("portal_id","copilot_id","entity_type","event_type") WHERE "qb_sync_logs"."deleted_at" IS NULL AND ( - ("qb_sync_logs"."entity_type" = 'invoice' AND "qb_sync_logs"."event_type" IN ('created','paid','voided','deleted')) - OR "qb_sync_logs"."entity_type" = 'payment' - ); diff --git a/src/db/migrations/meta/20260513111101_snapshot.json b/src/db/migrations/meta/20260513111101_snapshot.json deleted file mode 100644 index 2e543c31..00000000 --- a/src/db/migrations/meta/20260513111101_snapshot.json +++ /dev/null @@ -1,1107 +0,0 @@ -{ - "id": "056be0dd-8db9-4f73-bd22-cb8f5a2598ce", - "prevId": "4407e100-64fd-4a94-a638-4be325ce7d5c", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.qb_connection_logs": { - "name": "qb_connection_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portal_id": { - "name": "portal_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "connection_status": { - "name": "connection_status", - "type": "connection_statuses", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.qb_customers": { - "name": "qb_customers", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portal_id": { - "name": "portal_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "client_company_id": { - "name": "client_company_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "client_id": { - "name": "client_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "company_id": { - "name": "company_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "given_name": { - "name": "given_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "family_name": { - "name": "family_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "display_name": { - "name": "display_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "company_name": { - "name": "company_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "customer_type": { - "name": "customer_type", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'client'" - }, - "qb_sync_token": { - "name": "qb_sync_token", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "qb_customer_id": { - "name": "qb_customer_id", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uq_qb_customers_client_company_id_type_active_idx": { - "name": "uq_qb_customers_client_company_id_type_active_idx", - "columns": [ - { - "expression": "portal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "client_company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "customer_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"qb_customers\".\"deleted_at\" is null", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.qb_invoice_sync": { - "name": "qb_invoice_sync", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portal_id": { - "name": "portal_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "invoice_number": { - "name": "invoice_number", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "qb_invoice_id": { - "name": "qb_invoice_id", - "type": "varchar", - "primaryKey": false, - "notNull": false - }, - "qb_sync_token": { - "name": "qb_sync_token", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "recipient_id": { - "name": "recipient_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "invoice_statuses", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'open'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { - "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", - "columns": [ - { - "expression": "portal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "invoice_number", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"qb_invoice_sync\".\"deleted_at\" is null", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "qb_invoice_sync_customer_id_qb_customers_id_fk": { - "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", - "tableFrom": "qb_invoice_sync", - "tableTo": "qb_customers", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.qb_payment_sync": { - "name": "qb_payment_sync", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portal_id": { - "name": "portal_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "invoice_number": { - "name": "invoice_number", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "total_amount": { - "name": "total_amount", - "type": "numeric", - "primaryKey": false, - "notNull": true - }, - "qb_payment_id": { - "name": "qb_payment_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "qb_sync_token": { - "name": "qb_sync_token", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.qb_portal_connections": { - "name": "qb_portal_connections", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portal_id": { - "name": "portal_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "intuit_realm_id": { - "name": "intuit_realm_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "refresh_token": { - "name": "refresh_token", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "expires_in": { - "name": "expires_in", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "x_refresh_token_expires_in": { - "name": "x_refresh_token_expires_in", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "token_type": { - "name": "token_type", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "token_set_time": { - "name": "token_set_time", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "intiated_by": { - "name": "intiated_by", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "income_account_ref": { - "name": "income_account_ref", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "asset_account_ref": { - "name": "asset_account_ref", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "expense_account_ref": { - "name": "expense_account_ref", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "client_fee_ref": { - "name": "client_fee_ref", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "service_item_ref": { - "name": "service_item_ref", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "is_suspended": { - "name": "is_suspended", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "uq_qb_portal_connections_portal_id_idx": { - "name": "uq_qb_portal_connections_portal_id_idx", - "columns": [ - { - "expression": "portal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.qb_product_sync": { - "name": "qb_product_sync", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portal_id": { - "name": "portal_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "product_id": { - "name": "product_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "price_id": { - "name": "price_id", - "type": "varchar", - "primaryKey": false, - "notNull": false - }, - "name": { - "name": "name", - "type": "varchar", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "copilot_name": { - "name": "copilot_name", - "type": "varchar", - "primaryKey": false, - "notNull": false - }, - "unit_price": { - "name": "unit_price", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "copilot_unit_price": { - "name": "copilot_unit_price", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "qb_item_id": { - "name": "qb_item_id", - "type": "varchar", - "primaryKey": false, - "notNull": false - }, - "qb_sync_token": { - "name": "qb_sync_token", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "is_excluded": { - "name": "is_excluded", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.qb_settings": { - "name": "qb_settings", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portal_id": { - "name": "portal_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "absorbed_fee_flag": { - "name": "absorbed_fee_flag", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "company_name_flag": { - "name": "company_name_flag", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "create_new_product_flag": { - "name": "create_new_product_flag", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "initial_invoice_setting_map": { - "name": "initial_invoice_setting_map", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "initial_product_setting_map": { - "name": "initial_product_setting_map", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "sync_flag": { - "name": "sync_flag", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "is_enabled": { - "name": "is_enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { - "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", - "tableFrom": "qb_settings", - "tableTo": "qb_portal_connections", - "columnsFrom": [ - "portal_id" - ], - "columnsTo": [ - "portal_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.qb_sync_logs": { - "name": "qb_sync_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "portal_id": { - "name": "portal_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "entity_type": { - "name": "entity_type", - "type": "entity_types", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'invoice'" - }, - "event_type": { - "name": "event_type", - "type": "event_types", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'created'" - }, - "status": { - "name": "status", - "type": "log_statuses", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'success'" - }, - "sync_at": { - "name": "sync_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "copilot_id": { - "name": "copilot_id", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "quickbooks_id": { - "name": "quickbooks_id", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "invoice_number": { - "name": "invoice_number", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "amount": { - "name": "amount", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "remark": { - "name": "remark", - "type": "varchar", - "primaryKey": false, - "notNull": false - }, - "customer_name": { - "name": "customer_name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "customer_email": { - "name": "customer_email", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "tax_amount": { - "name": "tax_amount", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "fee_amount": { - "name": "fee_amount", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "product_name": { - "name": "product_name", - "type": "varchar", - "primaryKey": false, - "notNull": false - }, - "product_price": { - "name": "product_price", - "type": "numeric", - "primaryKey": false, - "notNull": false - }, - "qb_item_name": { - "name": "qb_item_name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "copilot_price_id": { - "name": "copilot_price_id", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "error_message": { - "name": "error_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "category": { - "name": "category", - "type": "failed_record_category_types", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'others'" - }, - "attempt": { - "name": "attempt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "idx_qb_sync_logs_lookup_active": { - "name": "idx_qb_sync_logs_lookup_active", - "columns": [ - { - "expression": "portal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "copilot_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "entity_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "event_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"qb_sync_logs\".\"deleted_at\" is null", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_qb_sync_logs_pending_reaper": { - "name": "idx_qb_sync_logs_pending_reaper", - "columns": [ - { - "expression": "portal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"qb_sync_logs\".\"deleted_at\" is null", - "concurrently": false, - "method": "btree", - "with": {} - }, - "uq_qb_sync_logs_oneshot_active": { - "name": "uq_qb_sync_logs_oneshot_active", - "columns": [ - { - "expression": "portal_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "copilot_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "entity_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "event_type", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR \"qb_sync_logs\".\"entity_type\" = 'payment'\n )", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.connection_statuses": { - "name": "connection_statuses", - "schema": "public", - "values": [ - "pending", - "success", - "error" - ] - }, - "public.invoice_statuses": { - "name": "invoice_statuses", - "schema": "public", - "values": [ - "draft", - "open", - "paid", - "void", - "deleted" - ] - }, - "public.entity_types": { - "name": "entity_types", - "schema": "public", - "values": [ - "invoice", - "product", - "payment" - ] - }, - "public.event_types": { - "name": "event_types", - "schema": "public", - "values": [ - "created", - "updated", - "paid", - "voided", - "deleted", - "succeeded", - "mapped", - "unmapped" - ] - }, - "public.failed_record_category_types": { - "name": "failed_record_category_types", - "schema": "public", - "values": [ - "auth", - "account", - "rate_limit", - "validation", - "qb_api_error", - "mapping_not_found", - "others" - ] - }, - "public.log_statuses": { - "name": "log_statuses", - "schema": "public", - "values": [ - "success", - "failed", - "info", - "pending" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file From df5ffbb336ee6df69b7806f7e2e81aee45827fab Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 14 May 2026 15:40:51 +0545 Subject: [PATCH 15/22] feat(OUT-3710): Sentry on walker exhaustion + populate qb_doc_number - captureSyncError helper in @/utils/sentry mirrors the existing addSyncBreadcrumb pattern. - resolveAvailableDocNumber wraps findNextAvailableDocNumber with a try/catch that captures (with portalId, assemblyInvoiceNumber, takenCount context) and re-throws. The existing FAILED-sync_log path still records the row; Sentry surfaces it for engineering attention since walker exhaustion is unrecoverable by resync. - Reduced MAX_SUFFIX_ATTEMPTS from 99 -> 10. Ten collisions on a single Assembly invoice number is already an extreme anomaly; surfacing earlier means alerts fire sooner without spending budget walking through dozens of futile suffixes. - Populate qb_doc_number on both qb_invoice_sync inserts: - webhookInvoiceCreated: qbDocNumber: docNumber (walker-resolved) - findOrMapInvoiceFromQBO: qbDocNumber: invoiceNumber (exact match) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/quickbooks/invoice/invoice.service.ts | 22 +++++++++++++++++-- .../api/quickbooks/invoice/invoice.utils.ts | 2 +- src/utils/sentry.ts | 8 +++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 4a425cc3..ebe780e4 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -52,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' @@ -561,7 +561,23 @@ export class InvoiceService extends BaseService { assemblyInvoiceNumber, ) const taken = new Set(existing.map((inv) => inv.DocNumber)) - return findNextAvailableDocNumber(assemblyInvoiceNumber, taken) + 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 + } } /** @@ -821,6 +837,7 @@ export class InvoiceService extends BaseService { 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 @@ -1426,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, diff --git a/src/app/api/quickbooks/invoice/invoice.utils.ts b/src/app/api/quickbooks/invoice/invoice.utils.ts index b6c3de8a..db56ea08 100644 --- a/src/app/api/quickbooks/invoice/invoice.utils.ts +++ b/src/app/api/quickbooks/invoice/invoice.utils.ts @@ -3,7 +3,7 @@ export const formatAssemblyInvoicePrivateNote = ( ): string => `Assembly invoice: ${invoiceNumber}` const QBO_DOCNUMBER_MAX_LENGTH = 21 -const MAX_SUFFIX_ATTEMPTS = 99 +const MAX_SUFFIX_ATTEMPTS = 10 /** * Given the Assembly invoice number and a set of DocNumbers already taken in diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts index 46a429f0..295f84f2 100644 --- a/src/utils/sentry.ts +++ b/src/utils/sentry.ts @@ -11,3 +11,11 @@ export function addSyncBreadcrumb( level: 'info', }) } + +export function captureSyncError( + error: unknown, + tags: Record, + extra?: Record, +) { + Sentry.captureException(error, { tags, extra }) +} From 71d58d56f902fd4f2abfe6c6f7a1f58d04ba0526 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 14 May 2026 15:41:05 +0545 Subject: [PATCH 16/22] fix(OUT-3710): wire payment-succeeded flow to qb_doc_number Both the live webhook path (webhook.service.ts) and the resync cron path (sync.service.ts) now resolve the canonical qb_doc_number from qb_invoice_sync and pass it to createExpenseForAbsorbedFees. Previously each used the Assembly invoice number directly, which would be wrong when the walker had suffixed the DocNumber. Legacy rows (created before the qb_doc_number column was added) will have qbDocNumber=null until backfilled. Both paths fall back to the Assembly invoice number in that case, matching the pre-OUT-3710 behavior. - webhookPaymentSucceeded and createExpenseForAbsorbedFees refactored to named-args. The latter now stores the explicit invoiceNumber on the sync_log row instead of payload.DocNumber, so qb_sync_logs. invoice_number is consistently the Assembly invoice number for PAYMENT/SUCCEEDED rows. - Both paths throw if the qb_invoice_sync mapping is missing: - Webhook path: APIError(NOT_FOUND) for HTTP semantics. - Sync cron path: plain Error since the cron has no HTTP response. - Stale unused imports (APIError, httpStatus, InvoiceResponse, QBInvoiceSelectSchemaType) cleaned up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/quickbooks/payment/payment.service.ts | 45 ++++++++++++------- src/app/api/quickbooks/sync/sync.service.ts | 28 +++++++++--- .../api/quickbooks/webhook/webhook.service.ts | 15 +++++-- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index af20bed5..ab3764ec 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -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, @@ -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') @@ -168,7 +174,7 @@ export class PaymentService extends BaseService { id, { qbInvoiceId: res.Purchase.Id, - invoiceNumber: payload.DocNumber, + invoiceNumber, }, EventType.SUCCEEDED, EntityType.PAYMENT, @@ -191,11 +197,17 @@ export class PaymentService extends BaseService { } } - async webhookPaymentSucceeded( - parsedPaymentSucceedResource: PaymentSucceededResponseType, - qbTokenInfo: IntuitAPITokensType, - invoice: InvoiceResponse | undefined, - ): Promise { + async webhookPaymentSucceeded({ + parsedPaymentSucceedResource, + qbTokenInfo, + qbDocNumber, + invoiceNumber, + }: { + parsedPaymentSucceedResource: PaymentSucceededResponseType + qbTokenInfo: IntuitAPITokensType + qbDocNumber: string + invoiceNumber: string + }): Promise { const paymentResource = parsedPaymentSucceedResource.data addSyncBreadcrumb('Payment succeeded flow started', { paymentId: paymentResource.id, @@ -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: [ { @@ -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( diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index cf4709ab..39a6b6e3 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -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( @@ -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: [ { @@ -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', diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 8bfd7d00..40df74ba 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -521,14 +521,23 @@ export class WebhookService extends BaseService { ) try { + const invService = new InvoiceService(this.user) + const invoiceSync = await invService.getInvoiceByNumber(invoice.number) + if (!invoiceSync) { + throw new APIError( + httpStatus.NOT_FOUND, + `No invoice found in invoice sync table for invoice id: ${parsedPaymentSucceedResource.data.invoiceId}`, + ) + } validateAccessToken(qbTokenInfo) // only track if the fee amount is paid by platform const paymentService = new PaymentService(this.user) - await paymentService.webhookPaymentSucceeded( + await paymentService.webhookPaymentSucceeded({ parsedPaymentSucceedResource, qbTokenInfo, - invoice, - ) + qbDocNumber: invoiceSync.qbDocNumber ?? invoice.number, + invoiceNumber: invoice.number, + }) } catch (error: unknown) { CustomLogger.error({ message: 'Webhook handler failed', obj: error }) const errorWithCode = getMessageAndCodeFromError(error) From 01dd7f7a2ef8c468bcdb911c7b2ad558c93e2c63 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 14 May 2026 15:43:11 +0545 Subject: [PATCH 17/22] chore(OUT-3720): update query to add column if not exist --- ...260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/migrations/20260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql b/src/db/migrations/20260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql index e4817288..3c39a4d0 100644 --- a/src/db/migrations/20260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql +++ b/src/db/migrations/20260514095346_add_qb_doc_number_column_in_qb_invoice_sync.sql @@ -1 +1 @@ -ALTER TABLE "qb_invoice_sync" ADD COLUMN "qb_doc_number" varchar; +ALTER TABLE "qb_invoice_sync" ADD COLUMN IF NOT EXISTS "qb_doc_number" varchar; From 673b99013dcbf294553f07bb71c09cdb5c8ca4e6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 15 May 2026 11:16:29 +0545 Subject: [PATCH 18/22] fix(OUT-3710): isQBODuplicateDocNumberError reads errors[].code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile review found the predicate was silently inoperative — APIError thrown by intuitAPI._createInvoice carries status=400, a boilerplate message, and the QBO fault payload in `errors[]`. None of the prior checks (.status/.code/.message) matched the real shape, so every 6240 race fell through to FAILED + resync instead of triggering the intended single re-walk retry. Predicate now iterates the `errors` array and matches `code === '6240'` or "Duplicate Document Number" in Detail/Message. Top-level .status/ .code/.message checks remain as defense-in-depth for non-APIError shapes or future call sites that rethrow the inner fault directly. Unit tests updated to use the real production shape: { status: 400, message: '#IntuitAPIErrorMessage#createInvoice', errors: [{ code: '6240', Detail: '…', Message: '…' }] } Pre-existing array-access bug in intuitAPI.ts:145 (treating Fault.Error as object) is out of scope for OUT-3710; predicate now handles the broken upstream shape correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/quickbooks/invoice/invoice.utils.ts | 40 +++++++++--- .../quickbooks/invoice/invoice.utils.test.ts | 61 ++++++++++++++++--- 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.utils.ts b/src/app/api/quickbooks/invoice/invoice.utils.ts index db56ea08..abbb2338 100644 --- a/src/app/api/quickbooks/invoice/invoice.utils.ts +++ b/src/app/api/quickbooks/invoice/invoice.utils.ts @@ -40,13 +40,20 @@ export const findNextAvailableDocNumber = ( } /** - * Recognizes QBO Error 6240 "Duplicate Document Number" across error shapes. + * Recognizes QBO Error 6240 "Duplicate Document Number" across the shapes it + * surfaces in. * - * The `.status`/`.code` branches are forward-compatible: today `intuitAPI.ts` - * reads `Fault.Error?.code` as if it were an object (it's actually an array), - * so APIError lands with status=400, not 6240. The live safety net is the - * regex over `.message`. When the array-access is corrected the structured - * branches will start firing too. + * 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 @@ -54,9 +61,26 @@ export const isQBODuplicateDocNumberError = (err: unknown): boolean => { 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 - const message = e.message ?? '' - return /6240|Duplicate Document Number/i.test(message) + return /6240|Duplicate Document Number/i.test(e.message ?? '') } diff --git a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts index 46b2eba3..c78eddc1 100644 --- a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts +++ b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts @@ -78,25 +78,69 @@ describe('findNextAvailableDocNumber', () => { }) describe('isQBODuplicateDocNumberError', () => { - it('matches numeric 6240 code', () => { - expect(isQBODuplicateDocNumberError({ code: 6240 })).toBe(true) + it('matches the real APIError shape thrown by intuitAPI._createInvoice', () => { + // This is the actual shape: status=400, message=boilerplate, errors=array + // of QBO fault objects. The 6240 code lives in errors[i].code. + const realApiError = { + status: 400, + message: '#IntuitAPIErrorMessage#createInvoice', + errors: [ + { + code: '6240', + Message: 'Duplicate Document Number Error', + Detail: + 'Duplicate Document Number Error : You must specify a different number. This number has already been used.', + Element: '', + }, + ], + } + expect(isQBODuplicateDocNumberError(realApiError)).toBe(true) }) - it('matches stringified 6240 code', () => { - expect(isQBODuplicateDocNumberError({ code: '6240' })).toBe(true) + + it('matches when errors[i].code is numeric', () => { + expect( + isQBODuplicateDocNumberError({ + status: 400, + errors: [{ code: 6240 }], + }), + ).toBe(true) + }) + + it('matches via errors[i].Detail when code is absent', () => { + expect( + isQBODuplicateDocNumberError({ + status: 400, + errors: [{ Detail: 'Duplicate Document Number Error: …' }], + }), + ).toBe(true) }) - it('matches numeric 6240 status (APIError shape from createInvoice)', () => { + + it('matches top-level .status as 6240 (defense-in-depth)', () => { expect(isQBODuplicateDocNumberError({ status: 6240 })).toBe(true) }) - it('matches stringified 6240 status', () => { - expect(isQBODuplicateDocNumberError({ status: '6240' })).toBe(true) + + it('matches top-level .code as 6240 (defense-in-depth)', () => { + expect(isQBODuplicateDocNumberError({ code: '6240' })).toBe(true) }) - it('matches the duplicate-doc-number message', () => { + + it('matches top-level .message text (defense-in-depth)', () => { expect( isQBODuplicateDocNumberError({ message: 'Duplicate Document Number Error', }), ).toBe(true) }) + + it('returns false for unrelated APIError shapes', () => { + expect( + isQBODuplicateDocNumberError({ + status: 400, + message: '#IntuitAPIErrorMessage#createInvoice', + errors: [{ code: '5010', Detail: 'Stale object error' }], + }), + ).toBe(false) + }) + it('returns false for unrelated errors', () => { expect(isQBODuplicateDocNumberError({ code: 5010 })).toBe(false) expect(isQBODuplicateDocNumberError({ status: 400 })).toBe(false) @@ -104,5 +148,6 @@ describe('isQBODuplicateDocNumberError', () => { expect(isQBODuplicateDocNumberError(undefined)).toBe(false) expect(isQBODuplicateDocNumberError('not an object')).toBe(false) expect(isQBODuplicateDocNumberError({})).toBe(false) + expect(isQBODuplicateDocNumberError([])).toBe(false) }) }) From 89b62b58ed6d455b779c9e8476a51ee6ba81b1d0 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 15 May 2026 11:18:30 +0545 Subject: [PATCH 19/22] test(OUT-3710): align exhaustion test with MAX_SUFFIX_ATTEMPTS constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exporting MAX_SUFFIX_ATTEMPTS and using it in the test removes the fragility where the test filled 1–99 against a cap that is now 10. If the cap is ever raised above the hard-coded fill size, the test would silently turn green on the wrong outcome (function finding a free slot past the fill range instead of throwing). Now the test fills exactly up to the cap, so any change to MAX_SUFFIX_ATTEMPTS keeps the boundary aligned. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/quickbooks/invoice/invoice.utils.ts | 2 +- test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.utils.ts b/src/app/api/quickbooks/invoice/invoice.utils.ts index abbb2338..6421b76c 100644 --- a/src/app/api/quickbooks/invoice/invoice.utils.ts +++ b/src/app/api/quickbooks/invoice/invoice.utils.ts @@ -3,7 +3,7 @@ export const formatAssemblyInvoicePrivateNote = ( ): string => `Assembly invoice: ${invoiceNumber}` const QBO_DOCNUMBER_MAX_LENGTH = 21 -const MAX_SUFFIX_ATTEMPTS = 10 +export const MAX_SUFFIX_ATTEMPTS = 10 /** * Given the Assembly invoice number and a set of DocNumbers already taken in diff --git a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts index c78eddc1..3c27128c 100644 --- a/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts +++ b/test/unit/app/api/quickbooks/invoice/invoice.utils.test.ts @@ -3,6 +3,7 @@ import { findNextAvailableDocNumber, formatAssemblyInvoicePrivateNote, isQBODuplicateDocNumberError, + MAX_SUFFIX_ATTEMPTS, } from '@/app/api/quickbooks/invoice/invoice.utils' describe('formatAssemblyInvoicePrivateNote', () => { @@ -67,10 +68,10 @@ describe('findNextAvailableDocNumber', () => { ) }) - it('throws after exhausting 99 suffix slots', () => { + it('throws after exhausting MAX_SUFFIX_ATTEMPTS slots', () => { const base = 'TEST-001' const taken = new Set([base]) - for (let n = 1; n <= 99; n++) taken.add(`${base}-${n}`) + for (let n = 1; n <= MAX_SUFFIX_ATTEMPTS; n++) taken.add(`${base}-${n}`) expect(() => findNextAvailableDocNumber(base, taken)).toThrow( /no available DocNumber/, ) From da60babd5eee93c6cca8df261071298c0e1a099b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 15 May 2026 11:37:49 +0545 Subject: [PATCH 20/22] fix(OUT-3710): align with OUT-3543's tightened envelope schemas OUT-3543 narrowed customQuery's return type from any to unknown and introduced QBInvoiceQueryResponseSchema for parsing list responses, matching how _getInvoice already worked. Two spots in this branch were lagging: - _findInvoicesByDocNumberPrefix previously accessed response.Invoice directly, which no longer typechecks against unknown. Switched to QBInvoiceQueryResponseSchema.parse(response) mirroring _getInvoice's pattern. The schema is already imported. - The createInvoice unit tests in intuitAPI.responses.test.ts built payloads without PrivateNote, which is now required by QBInvoiceCreatePayloadSchema (OUT-3710 commit 40a5052). Added the canonical Assembly-invoice fixture string. No behavior change; both were typecheck-only regressions surfaced by running tsc against the rebased branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils/intuitAPI.ts | 5 +++-- test/unit/utils/intuitAPI.responses.test.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 0756ef3f..ff37cf29 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -726,8 +726,9 @@ export default class IntuitAPI { 'IntuitAPI#findInvoicesByDocNumberPrefix | message = no response', ) } - if (!response.Invoice) return [] - return response.Invoice.map((inv: { Id: string; DocNumber?: string }) => ({ + const envelope = QBInvoiceQueryResponseSchema.parse(response) + if (!envelope.Invoice) return [] + return envelope.Invoice.map((inv) => ({ Id: inv.Id, DocNumber: inv.DocNumber ?? '', })) diff --git a/test/unit/utils/intuitAPI.responses.test.ts b/test/unit/utils/intuitAPI.responses.test.ts index c83cca44..8fbd699f 100644 --- a/test/unit/utils/intuitAPI.responses.test.ts +++ b/test/unit/utils/intuitAPI.responses.test.ts @@ -268,6 +268,7 @@ describe('IntuitAPI POST-based writes', () => { const result = await api.createInvoice({ Line: [], CustomerRef: { value: 'c1' }, + PrivateNote: 'Assembly invoice: TEST-001', }) expect(result.Invoice.Id).toBe('500') @@ -279,7 +280,7 @@ describe('IntuitAPI POST-based writes', () => { const api = makeApi() await expect( - api.createInvoice({ Line: [], CustomerRef: { value: 'c1' } }), + api.createInvoice({ Line: [], CustomerRef: { value: 'c1' }, PrivateNote: 'Assembly invoice: TEST-001' }), ).rejects.toBeInstanceOf(APIError) }) From bd9d5f457973d2c927d0acd66ad45981f2e616a1 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 15 May 2026 12:08:31 +0545 Subject: [PATCH 21/22] chore(OUT-3710): fix lint --- test/unit/utils/intuitAPI.responses.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/unit/utils/intuitAPI.responses.test.ts b/test/unit/utils/intuitAPI.responses.test.ts index 8fbd699f..aac7d171 100644 --- a/test/unit/utils/intuitAPI.responses.test.ts +++ b/test/unit/utils/intuitAPI.responses.test.ts @@ -280,7 +280,11 @@ describe('IntuitAPI POST-based writes', () => { const api = makeApi() await expect( - api.createInvoice({ Line: [], CustomerRef: { value: 'c1' }, PrivateNote: 'Assembly invoice: TEST-001' }), + api.createInvoice({ + Line: [], + CustomerRef: { value: 'c1' }, + PrivateNote: 'Assembly invoice: TEST-001', + }), ).rejects.toBeInstanceOf(APIError) }) From e836e62753b15a95ab19e24821459da6a5941594 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 15 May 2026 14:51:53 +0545 Subject: [PATCH 22/22] fix(OUT-3710): include SyncToken while fetching invoices --- src/utils/intuitAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index ff37cf29..e9b00ccb 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -718,7 +718,7 @@ export default class IntuitAPI { const escapedPrefix = escapeForQBQuery(prefix) .replace(/%/g, '\\%') .replace(/_/g, '\\_') - const query = `select Id, DocNumber from Invoice where DocNumber LIKE '${escapedPrefix}%' maxresults 100` + const query = `select Id, SyncToken, DocNumber from Invoice where DocNumber LIKE '${escapedPrefix}%' maxresults 100` const response = await this.customQuery(query) if (!response) { throw new APIError(