From 9994762238574b1edc5b86daad30b67d0ddc5fa3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Apr 2026 20:47:37 +0545 Subject: [PATCH 01/14] feat(OUT-3528): add error_code column to qb_sync_logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persists the QBO Fault.Error.code on each sync log row so failures can be routed to a notification action and filtered in the sync history CSV by error type. Nullable varchar(50) — zero-downtime migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/schema/qbSyncLogs.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/db/schema/qbSyncLogs.ts b/src/db/schema/qbSyncLogs.ts index d4c55573..6675c2ef 100644 --- a/src/db/schema/qbSyncLogs.ts +++ b/src/db/schema/qbSyncLogs.ts @@ -46,6 +46,11 @@ export const QBSyncLog = table( qbItemName: t.varchar('qb_item_name', { length: 100 }), copilotPriceId: t.varchar('copilot_price_id', { length: 100 }), errorMessage: t.text('error_message'), + // QBO error code (e.g. '6140', '6210') captured from intuit Fault payloads + // when status is FAILED. Used by SyncErrorNotifier to route + throttle + // user-actionable failure notifications. Nullable because non-Intuit + // failures (validation, mapping_not_found, etc.) don't carry one. + errorCode: t.varchar('error_code', { length: 50 }), category: FailedCategoryEnum('category') .default(FailedRecordCategoryType.OTHERS) .notNull(), From d9a06290fcd024443040ab19a4dc8a9d10e6d7b1 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Apr 2026 20:47:54 +0545 Subject: [PATCH 02/14] feat(OUT-3528): plumb QBO error codes into sync log writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMessageAndCodeFromError now returns the Intuit Fault.Error.code (e.g. 6140) instead of the HTTP status when the error originates from QBO; renames IntuitErrorType.Code → code to match the actual payload casing. Each FAILED-path createQBSyncLog / updateQBSyncLog call site (webhook, payment, sync resync) now passes the resulting errorCode so it lands in qb_sync_logs.error_code. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/quickbooks/payment/payment.service.ts | 2 ++ src/app/api/quickbooks/sync/sync.service.ts | 1 + src/app/api/quickbooks/webhook/webhook.service.ts | 5 +++++ src/utils/error.ts | 9 ++++++--- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index ab3764ec..2064f77e 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -139,6 +139,7 @@ export class PaymentService extends BaseService { customerName: recipientInfo.displayName, customerEmail: recipientInfo.email, errorMessage, + errorCode: errorWithCode.code?.toString(), category: getCategory(errorWithCode), deletedAt: getDeletedAtForAuthAccountCategoryLog(errorWithCode), }, @@ -272,6 +273,7 @@ export class PaymentService extends BaseService { remark?: string qbItemName?: string errorMessage?: string + errorCode?: string category?: FailedRecordCategoryType deletedAt?: Date }, diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index 39a6b6e3..3b72372e 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -649,6 +649,7 @@ export class SyncService extends BaseService { { status: LogStatus.FAILED, errorMessage, + errorCode: error?.code?.toString(), deletedAt: getDeletedAtForAuthAccountCategoryLog(error), category: getCategory(error), }, diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 40df74ba..2ad4de8f 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -136,6 +136,7 @@ export class WebhookService extends BaseService { amount: total?.toFixed(2), invoiceNumber, errorMessage, + errorCode: error?.code?.toString(), deletedAt: getDeletedAtForAuthAccountCategoryLog(error), category: getCategory(error), }) @@ -354,6 +355,7 @@ export class WebhookService extends BaseService { invoiceNumber: parsedPaidInvoiceResource.data.number, amount: parsedPaidInvoiceResource.data.total.toFixed(2), errorMessage, + errorCode: errorWithCode.code?.toString(), deletedAt: getDeletedAtForAuthAccountCategoryLog(errorWithCode), category: getCategory(errorWithCode), }) @@ -399,6 +401,7 @@ export class WebhookService extends BaseService { copilotId: parsedProductResource.data.id, productName: parsedProductResource.data.name, errorMessage, + errorCode: errorWithCode.code?.toString(), deletedAt: getDeletedAtForAuthAccountCategoryLog(errorWithCode), category: getCategory(errorWithCode), }) @@ -454,6 +457,7 @@ export class WebhookService extends BaseService { productPrice: priceResource.amount?.toFixed(2), copilotPriceId: priceResource.id, errorMessage, + errorCode: errorWithCode.code?.toString(), deletedAt: getDeletedAtForAuthAccountCategoryLog(errorWithCode), category: getCategory(errorWithCode), }, @@ -555,6 +559,7 @@ export class WebhookService extends BaseService { remark: 'Absorbed fees', qbItemName: 'Assembly Fees', errorMessage, + errorCode: errorWithCode.code?.toString(), deletedAt: getDeletedAtForAuthAccountCategoryLog(errorWithCode), category: getCategory(errorWithCode), }) diff --git a/src/utils/error.ts b/src/utils/error.ts index 72c7a51c..5ac0c2e2 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -14,8 +14,8 @@ import { ZodError } from 'zod' export type IntuitErrorType = { Message: string Detail: string - Code: string - Element?: string + code: string + element?: string } export type ErrorMessageAndCode = { @@ -49,14 +49,17 @@ export const getMessageAndCodeFromError = ( } } else if (error instanceof APIError) { let errorMessage = error.message || message + let statusCode = error.status + const isIntuitError = error.message.includes(IntuitAPIErrorMessage) if (isIntuitError) { const firstFault = error.errors?.[0] as QBFaultErrorSchemaType | undefined errorMessage = firstFault?.Detail ?? errorMessage + statusCode = firstFault?.code ?? statusCode } return { message: errorMessage, - code: error.status, + code: statusCode, source: isIntuitError ? 'intuit' : 'unknown', } } else if (isIntuitOAuthError(error)) { From 46e64aa63f3f74651d4217f6408418418397c515 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Apr 2026 20:48:16 +0545 Subject: [PATCH 03/14] feat(OUT-3528): IU notifications for user-actionable QBO errors Maps 11 manual-fix QBO error codes (6140, 6240, 6210, 6540, 610, 2500, 6190, 6000, 5010, 620, 2390) to NotificationActions and provides per-action in-product + email copy via a single NotificationCopy registry. Bodies interpolate "(during X, ref Y)" from log entityType/eventType + identifier so IUs can pinpoint the failing record. Two appended advisories (MANUAL_EDIT_NOTE for recoverable failures, FINAL_FAILURE_NOTE for 5010 invoice). 5010 + 6240 branch on entityType (separate QBO namespaces); 6000/6190 keep cause-honest framing without manual-edit advisory. IU_RECIPIENT_ACTIONS in NotificationService is derived from the registry keys so a new action can't be added without an opt-in. Sentry capture added for dispatch failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/core/types/notification.ts | 29 ++ .../api/notification/notification.helper.ts | 280 ++++++++++++++++-- .../api/notification/notification.service.ts | 52 +++- src/constant/intuitErrorCode.ts | 26 ++ 4 files changed, 349 insertions(+), 38 deletions(-) diff --git a/src/app/api/core/types/notification.ts b/src/app/api/core/types/notification.ts index deaf619b..3c9fef0c 100644 --- a/src/app/api/core/types/notification.ts +++ b/src/app/api/core/types/notification.ts @@ -1,3 +1,32 @@ export enum NotificationActions { AUTH_RECONNECT = 'auth_reconnect', + QB_DUPLICATE_DOC_NUMBER = 'qb_duplicate_doc_number', + QB_DUPLICATE_NAME = 'qb_duplicate_name', + QB_CLOSED_PERIOD = 'qb_closed_period', + QB_DEPOSITED_TXN_LOCKED = 'qb_deposited_txn_locked', + QB_INACTIVE_REFERENCE = 'qb_inactive_reference', + QB_SUBSCRIPTION_INVALID = 'qb_subscription_invalid', + QB_VALIDATION_FAILED = 'qb_validation_failed', + QB_STALE_OBJECT = 'qb_stale_object', + QB_TXN_LINK_FAILED = 'qb_txn_link_failed', + QB_ITEM_INCOME_ACCOUNT_MISSING = 'qb_item_income_account_missing', +} + +/** + * Optional context passed alongside a NotificationActions value when dispatching + * a sync-failure notification. The notification helper uses these to interpolate + * a tailored title/body (e.g. naming the offending invoice number or QB item). + * + * All fields are optional so callers (like AUTH_RECONNECT) can still dispatch + * without context. + */ +export interface NotificationContext { + entityType?: string + eventType?: string + entityKey?: string + invoiceNumber?: string + customerName?: string + productName?: string + qbItemName?: string + errorMessage?: string } diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index a48aea24..8381d650 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -1,38 +1,260 @@ import { AuthStatus } from '@/app/api/core/types/auth' -import { NotificationActions } from '@/app/api/core/types/notification' +import { + NotificationActions, + NotificationContext, +} from '@/app/api/core/types/notification' -export const getInProductNotificationDetail = (): { - [key in NotificationActions]: { - title: string - body: string - ctaParams?: Record - } -} => { +// Appended to bodies where the failure was caused by a manual edit in +// QuickBooks. Communicates that (a) the record will be visible as failed in +// the sync history until the conflict is resolved, and (b) manual edits to +// synced records can disrupt the sync flow. +const MANUAL_EDIT_NOTE = + 'Until the conflict is resolved, this record will show as failed in the sync history. Manual edits to synced records in QuickBooks can disrupt the sync flow.' + +// Variant for the 5010 invoice case where the scheduled retry cannot recover +// (e.g. the invoice was voided directly in QuickBooks). The record stays +// FAILED indefinitely. +const FINAL_FAILURE_NOTE = + 'This record will remain marked as failed in the sync history. Manual edits to synced records in QuickBooks can disrupt the sync flow.' + +interface InProductNotificationDetail { + title: string + body: string + ctaParams?: Record +} + +interface EmailNotificationDetail { + title: string + subject: string + header: string + body: string + ctaParams?: Record +} + +// Defaults shared by every sync-failure email entry. AUTH_RECONNECT and +// QB_SUBSCRIPTION_INVALID override these; the other 9 actions match verbatim +// and so omit the corresponding fields below. +const DEFAULT_EMAIL_TITLE = 'QuickBooks sync failed' +const DEFAULT_EMAIL_HEADER = 'A QuickBooks sync failed' + +type BodyBuilder = (ref: string, ctx?: NotificationContext) => string + +interface NotificationActionEntry { + title: string + body: BodyBuilder + emailTitle?: string + emailSubject: string + emailHeader?: string + emailBody: BodyBuilder + ctaParams?: Record +} + +/** + * Maps a (entityType, eventType) pair to a short noun phrase like + * "invoice creation" or "product update". Used to tell IUs *which* sync + * action triggered the failure (e.g. 5010 during invoice void vs invoice + * paid). Falls back to '' when either dimension is missing. + */ +const describeAction = (entityType?: string, eventType?: string): string => { + if (!entityType || !eventType) return '' + const eventNoun = + ( + { + created: 'creation', + updated: 'update', + paid: 'payment', + voided: 'void', + deleted: 'deletion', + succeeded: 'completion', + mapped: 'mapping', + unmapped: 'unmapping', + } as Record + )[eventType] ?? eventType + return `${entityType} ${eventNoun}` +} + +/** + * Builds the parenthetical reference clause for a sync-failure notification + * body, e.g. " (during invoice void, ref INV-9)". Returns '' when called + * without context (AUTH_RECONNECT path). Sync-failure ctx always carries + * entityType + eventType (NOT NULL on qb_sync_logs) and one of the id fields, + * so we always render the full clause when ctx is present. + */ +const buildEntityReference = (ctx?: NotificationContext): string => { + if (!ctx) return '' + const action = describeAction(ctx.entityType, ctx.eventType) + const id = + ctx.invoiceNumber || + ctx.qbItemName || + ctx.productName || + ctx.customerName || + ctx.entityKey + return ` (during ${action}, ref ${id})` +} + +/** + * Per-action notification copy for both delivery channels. In-product and + * email strings are stored verbatim (not derived from each other) — they have + * been independently approved by product and differ in subtle, deliberate + * ways (e.g. in-product uses semicolons + "the next scheduled retry will pick + * it up"; email uses periods + "(within a few hours) automatically"). + * + * The keys here also drive `IU_RECIPIENT_ACTIONS` in notification.service.ts + * — adding an entry automatically opts the action into IU dispatch. + */ +export const NotificationCopy: Record< + NotificationActions, + NotificationActionEntry +> = { + [NotificationActions.AUTH_RECONNECT]: { + title: 'QuickBooks Sync has disconnected', + body: () => + 'Your QuickBooks Sync encountered an error and has stopped syncing. Please reconnect to avoid any disruptions.', + emailTitle: 'Reconnect QuickBooks', + emailSubject: 'Your QuickBooks Sync has disconnected', + emailHeader: 'QuickBooks Sync has disconnected', + emailBody: () => + 'Your QuickBooks integration encountered an error and has stopped syncing. Please reconnect to avoid any disruptions.', + // TODO: CTA params not working for email + ctaParams: { type: AuthStatus.RECONNECT }, + }, + + [NotificationActions.QB_DUPLICATE_DOC_NUMBER]: { + title: 'QuickBooks sync failed: duplicate document number', + body: (ref) => + `A sync failed${ref} because another invoice in QuickBooks already uses this document number. This often happens when "Custom transaction numbers" is enabled and the same number was reused, or an invoice with that number was created manually in QuickBooks. Change the document number on the conflicting invoice in QuickBooks; we re-attempt failed syncs every few hours, so the next run should pick it up. ${MANUAL_EDIT_NOTE}`, + emailSubject: 'QuickBooks sync failed: duplicate document number', + emailBody: (ref) => + `A sync failed${ref} because another invoice in QuickBooks already uses this document number. This often happens when "Custom transaction numbers" is enabled in QuickBooks and the same number was reused, or an invoice with that number was created manually. Change the document number on the conflicting invoice in QuickBooks. We re-attempt failed syncs automatically every few hours, so the next run should pick it up — no further action needed in this app. ${MANUAL_EDIT_NOTE}`, + }, + + // 6240 lives in two QBO namespaces: Items have their own list, while + // Customer/Vendor/Employee share one. The body branches on entityType + // because the fix is namespace-specific. + [NotificationActions.QB_DUPLICATE_NAME]: { + title: 'QuickBooks sync failed: name already in use', + body: (ref, ctx) => + ctx?.entityType === 'product' + ? `A sync failed${ref} because the item's name conflicts with an existing item in QuickBooks. This typically happens when an item with the same name was added in QuickBooks. Rename the conflicting item in QuickBooks; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}` + : `A sync failed${ref} because the customer's display name conflicts with an existing Customer, Vendor, or Employee in QuickBooks. We automatically try up to 20 numbered variations of the name to avoid this, so this notification means every variation is taken — typically because a Vendor or Employee was added in QuickBooks with the same base name. Rename the conflicting Vendor or Employee in QuickBooks; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, + emailSubject: 'QuickBooks sync failed: name already in use', + emailBody: (ref, ctx) => + ctx?.entityType === 'product' + ? `A sync failed${ref} because the item's name conflicts with an existing item in QuickBooks. This typically happens when an item with the same name was added in QuickBooks. Rename the conflicting item in QuickBooks. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}` + : `A sync failed${ref} because the customer's display name conflicts with an existing Customer, Vendor, or Employee in QuickBooks. We automatically try up to 20 numbered variations of the name to work around this, so this email means every variation is taken — typically because a Vendor or Employee was added in QuickBooks with the same base name. Rename the conflicting Vendor or Employee in QuickBooks. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, + }, + + [NotificationActions.QB_CLOSED_PERIOD]: { + title: 'QuickBooks sync failed: accounting period is closed', + body: (ref) => + `A sync failed${ref} because the transaction date falls in a closed accounting period. This usually happens after a fiscal year close or when a closing date was set in Settings → Account and Settings → Advanced in QuickBooks. Reopen the period or move the closing date in QuickBooks; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, + emailSubject: 'QuickBooks sync failed: accounting period is closed', + emailBody: (ref) => + `A sync failed${ref} because the transaction date falls in a closed accounting period in QuickBooks. This usually happens after a fiscal year close or when a closing date was set in Settings → Account and Settings → Advanced. Reopen the period or move the closing date in QuickBooks. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, + }, + + [NotificationActions.QB_DEPOSITED_TXN_LOCKED]: { + title: 'QuickBooks sync failed: transaction is deposited', + body: (ref) => + `A sync failed${ref} because the transaction has been added to a bank deposit in QuickBooks, which locks it from edits. This usually happens when the payment was recorded as part of a deposit in QuickBooks. Remove the transaction from the deposit (or undo the deposit) in QuickBooks; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, + emailSubject: 'QuickBooks sync failed: transaction is deposited', + emailBody: (ref) => + `A sync failed${ref} because the transaction has been added to a bank deposit in QuickBooks, which locks it from edits. This usually happens when the payment was recorded as part of a deposit in QuickBooks. Remove the transaction from the deposit (or undo the deposit) in QuickBooks. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, + }, + + [NotificationActions.QB_INACTIVE_REFERENCE]: { + title: 'QuickBooks sync failed: referenced record is inactive', + body: (ref) => + `A sync failed${ref} because a referenced QuickBooks record (a customer, item, or account) was deleted or marked inactive. This often happens when someone cleaned up records in QuickBooks while a related transaction was still in flight, or when a service mapping points to a deleted item. Reactivate the record in QuickBooks, or update the relevant entry under Service Mapping in this app's settings; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, + emailSubject: 'QuickBooks sync failed: referenced record is inactive', + emailBody: (ref) => + `A sync failed${ref} because a referenced QuickBooks record (a customer, item, or account) was deleted or marked inactive. This often happens when records are cleaned up in QuickBooks while a related transaction is still in flight, or when a Service Mapping in this app points to an item that no longer exists. Reactivate the record in QuickBooks, or update the entry under Service Mapping in this app's settings. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, + }, + + [NotificationActions.QB_SUBSCRIPTION_INVALID]: { + title: 'QuickBooks subscription issue is blocking sync', + body: (ref) => + `Syncs are failing${ref} because your QuickBooks subscription is suspended, expired, or has a billing issue. QuickBooks blocks all write operations until the subscription is active again, so every sync attempt for this portal will fail until billing is resolved. Update your QuickBooks subscription; we'll automatically resume syncing on the next scheduled retry.`, + emailTitle: 'QuickBooks subscription issue', + emailSubject: 'QuickBooks subscription issue is blocking sync', + emailHeader: 'QuickBooks subscription issue is blocking sync', + emailBody: (ref) => + `Syncs are failing${ref} because your QuickBooks subscription is suspended, expired, or has a billing issue. QuickBooks blocks all write operations until the subscription is active again, so every sync attempt for this portal will fail until billing is resolved. Update your QuickBooks subscription. We'll automatically resume syncing on the next scheduled retry — no action needed in this app.`, + }, + + [NotificationActions.QB_VALIDATION_FAILED]: { + title: 'QuickBooks sync failed: validation error', + body: (ref) => + `QuickBooks rejected a sync${ref} because the data didn't pass one of its business rules. This often happens when required setup is missing — for example a default income account on an item, tax setup, or a customer's billing address. Download the sync history from the app menu to see the exact error message, then resolve it in QuickBooks; the next scheduled retry will pick it up.`, + emailSubject: 'QuickBooks sync failed: validation error', + emailBody: (ref) => + `QuickBooks rejected a sync${ref} because the data didn't pass one of its business rules. This often happens when required setup is missing in QuickBooks — for example a default income account on an item, tax setup, or a customer's billing address. To see the exact error, download the sync history CSV from the app menu in this app and check the error_message column. Once you've resolved the issue in QuickBooks, the next scheduled retry (within a few hours) will pick it up automatically.`, + }, + + // Suppression in SyncErrorNotifier guarantees we only reach this branch + // for invoice 5010 — product/customer 5010 auto-recovers via syncToken + // refresh on the next retry and is not surfaced to IUs. + // + // The body intentionally avoids prescribing a specific fix (e.g. "undo the + // void"): 5010 only tells us our cached SyncToken is behind, not *what* + // changed in QuickBooks. The change could be an edit, void, deletion, or + // anything else. + [NotificationActions.QB_STALE_OBJECT]: { + title: 'QuickBooks sync failed: record was edited elsewhere', + body: (ref) => + `A sync failed${ref} because the invoice in QuickBooks has changed since we last read it — it may have been edited, voided, deleted, or otherwise modified directly in QuickBooks. The scheduled retry cannot recover this automatically because our pending change targets a version that is no longer current. Open the invoice in QuickBooks to confirm its current state matches what you expect. ${FINAL_FAILURE_NOTE}`, + emailSubject: 'QuickBooks sync failed: record was edited elsewhere', + emailBody: (ref) => + `A sync failed${ref} because the invoice in QuickBooks has changed since we last read it — it may have been edited, voided, deleted, or otherwise modified directly in QuickBooks. The scheduled retry cannot recover this automatically, because our pending change targets a version that is no longer current. Open the invoice in QuickBooks to confirm its current state matches what you expect. ${FINAL_FAILURE_NOTE}`, + }, + + // 620 only tells us QuickBooks refused the link — not which related record + // is the problem or what happened to it. Body avoids prescribing a specific + // fix and conditions the retry promise on the conflict actually being + // resolvable. + [NotificationActions.QB_TXN_LINK_FAILED]: { + title: 'QuickBooks sync failed: transaction cannot be linked', + body: (ref) => + `A sync failed${ref} because QuickBooks could not link the transaction to a related record. The linked customer, invoice, or payment may have been updated, voided, deleted, or otherwise made unlinkable in QuickBooks. Review the related records in QuickBooks to identify the conflict; if you can restore the linked record to a usable state (for example by reactivating it), the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, + emailSubject: 'QuickBooks sync failed: transaction cannot be linked', + emailBody: (ref) => + `A sync failed${ref} because QuickBooks could not link the transaction to a related record. The linked customer, invoice, or payment may have been updated, voided, deleted, or otherwise made unlinkable in QuickBooks. Review the related records in QuickBooks to identify the conflict. If you can restore the linked record to a usable state (for example by reactivating it), the next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, + }, + + [NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING]: { + title: 'QuickBooks sync failed: item is missing an income account', + body: (ref) => + `A sync failed${ref} because a QuickBooks item has no income account assigned. This usually happens when the item was created in QuickBooks without an income account, or the account was removed afterwards. Open Products and Services in QuickBooks, edit the item, and set its income account; the next scheduled retry will pick it up.`, + emailSubject: 'QuickBooks sync failed: item is missing an income account', + emailBody: (ref) => + `A sync failed${ref} because a QuickBooks item has no income account assigned. This usually happens when the item was created in QuickBooks without an income account, or the account was removed afterwards. Open Products and Services in QuickBooks, edit the item, and set its income account. The next scheduled retry (within a few hours) will pick it up automatically.`, + }, +} + +export const getInProductNotificationDetail = ( + action: NotificationActions, + ctx?: NotificationContext, +): InProductNotificationDetail => { + const entry = NotificationCopy[action] + const ref = buildEntityReference(ctx) return { - [NotificationActions.AUTH_RECONNECT]: { - title: 'QuickBooks Sync has disconnected', - body: 'Your QuickBooks Sync encountered an error and has stopped syncing. Please reconnect to avoid any disruptions.', - ctaParams: { type: AuthStatus.RECONNECT }, - }, + title: entry.title, + body: entry.body(ref, ctx), + ...(entry.ctaParams && { ctaParams: entry.ctaParams }), } } -export const getIEmailNotificationDetail = (): { - [key in NotificationActions]: { - title: string - subject: string - header: string - body: string - ctaParams?: Record - } -} => { +export const getIEmailNotificationDetail = ( + action: NotificationActions, + ctx?: NotificationContext, +): EmailNotificationDetail => { + const entry = NotificationCopy[action] + const ref = buildEntityReference(ctx) return { - [NotificationActions.AUTH_RECONNECT]: { - title: 'Reconnect QuickBooks', - subject: 'Your QuickBooks Sync has disconnected', - header: 'QuickBooks Sync has disconnected', - body: 'Your QuickBooks integration encountered an error and has stopped syncing. Please reconnect to avoid any disruptions.', - ctaParams: { type: AuthStatus.RECONNECT }, // TODO: CTA params not working for email - }, + title: entry.emailTitle ?? DEFAULT_EMAIL_TITLE, + subject: entry.emailSubject, + header: entry.emailHeader ?? DEFAULT_EMAIL_HEADER, + body: entry.emailBody(ref, ctx), + ...(entry.ctaParams && { ctaParams: entry.ctaParams }), } } diff --git a/src/app/api/notification/notification.service.ts b/src/app/api/notification/notification.service.ts index 8539d832..adb51974 100644 --- a/src/app/api/notification/notification.service.ts +++ b/src/app/api/notification/notification.service.ts @@ -1,12 +1,25 @@ import { BaseService } from '@/app/api/core/services/base.service' -import { NotificationActions } from '@/app/api/core/types/notification' import { + NotificationActions, + NotificationContext, +} from '@/app/api/core/types/notification' +import { + NotificationCopy, getIEmailNotificationDetail, getInProductNotificationDetail, } from '@/app/api/notification/notification.helper' import { InternalUsersResponse } from '@/type/common' import { CopilotAPI } from '@/utils/copilotAPI' import CustomLogger from '@/utils/logger' +import { captureException, captureMessage } from '@sentry/nextjs' + +// Notification actions whose recipients are the workspace's IUs. Derived +// directly from the keys of NotificationCopy so a new action can't accidentally +// be added to the helper without an IU recipient set — getAllParties returns +// `null` for any action missing here, which short-circuits dispatch. +const IU_RECIPIENT_ACTIONS = new Set( + Object.keys(NotificationCopy) as NotificationActions[], +) export class NotificationService extends BaseService { async createBulkNotification( @@ -15,10 +28,12 @@ export class NotificationService extends BaseService { disableEmail = false, disableInProduct = false, senderId, + context, }: { disableEmail?: boolean disableInProduct?: boolean senderId: string + context?: NotificationContext }, ): Promise { console.info( @@ -33,11 +48,11 @@ export class NotificationService extends BaseService { if (parties) { const inProduct = disableInProduct ? undefined - : getInProductNotificationDetail()[action] + : getInProductNotificationDetail(action, context) const email = disableEmail ? undefined - : getIEmailNotificationDetail()[action] + : getIEmailNotificationDetail(action, context) for (const party of parties.data) { CustomLogger.info({ @@ -56,6 +71,18 @@ export class NotificationService extends BaseService { console.error( `Failed to trigger notification for IUID: ${party.id}`, ) + captureMessage( + `NotificationService#createBulkNotification | Copilot returned no notification`, + { + level: 'error', + tags: { + key: 'notificationDispatchFailed', + action, + portalId: this.user.workspaceId, + }, + extra: { recipientId: party.id, senderId }, + }, + ) } } } @@ -63,6 +90,14 @@ export class NotificationService extends BaseService { console.error(`Failed to send notification for action: ${action}`, { error, }) + captureException(error, { + tags: { + key: 'notificationDispatchFailed', + action, + portalId: this.user.workspaceId, + }, + extra: { senderId }, + }) } } @@ -70,18 +105,17 @@ export class NotificationService extends BaseService { copilot: CopilotAPI, action: NotificationActions, ): Promise { - switch (action) { - case NotificationActions.AUTH_RECONNECT: - return await copilot.getInternalUsers() - default: - return null + if (IU_RECIPIENT_ACTIONS.has(action)) { + return await copilot.getInternalUsers() } + return null } async sendNotificationToIU( senderId: string, action: NotificationActions, + context?: NotificationContext, ): Promise { - await this.createBulkNotification(action, { senderId }) + await this.createBulkNotification(action, { senderId, context }) } } diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index db1718a8..23b4c360 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -1,3 +1,5 @@ +import { NotificationActions } from '@/app/api/core/types/notification' + // Doc: https://developer.intuit.com/app/developer/qbo/docs/develop/troubleshooting/error-codes export const QBOErrorCodes = { BUSINESS_VALIDATION: 6000, @@ -16,3 +18,27 @@ export const AccountErrorCodes: readonly number[] = [ export const OAuthErrorCodes = { INVALID_GRANT: 'invalid_grant', } as const + +/** + * Maps QBO error codes to the IU notification action they should trigger. + * + * Scope: only codes that require a human to fix data/settings in QuickBooks. + * Transient (429, 5xx) and auth (120, 401/invalid_grant) errors are handled + * elsewhere — 429/5xx by retry, auth by AUTH_RECONNECT. + * + * Keyed by string because intuit Fault.Error.code arrives as a string in + * payloads and we persist it as varchar in qb_sync_logs.error_code. + */ +export const UserActionableErrorCodes: Record = { + '6140': NotificationActions.QB_DUPLICATE_DOC_NUMBER, + '6240': NotificationActions.QB_DUPLICATE_NAME, + '6210': NotificationActions.QB_CLOSED_PERIOD, + '6540': NotificationActions.QB_DEPOSITED_TXN_LOCKED, + '610': NotificationActions.QB_INACTIVE_REFERENCE, + '2500': NotificationActions.QB_INACTIVE_REFERENCE, + '6190': NotificationActions.QB_SUBSCRIPTION_INVALID, + '6000': NotificationActions.QB_VALIDATION_FAILED, + '5010': NotificationActions.QB_STALE_OBJECT, + '620': NotificationActions.QB_TXN_LINK_FAILED, + '2390': NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING, +} From c740e1a4570c719521afd36c4950e96989c61959 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Apr 2026 20:48:37 +0545 Subject: [PATCH 04/14] feat(OUT-3528): dispatch sync-failure notifications via SyncErrorNotifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyncLogService schedules an IU notification on every transition INTO LogStatus.FAILED (PENDING/SUCCESS → FAILED). Retries that keep a row FAILED do not re-page; partial updates that don't set status short-circuit before any DB lookup. Dispatch runs in afterIfAvailable so the request scope (and any enclosing transaction) commits first; notifier failures are captured to Sentry and never undo the sync log write. 5010 stale-object on products and customers is suppressed because those flows pre-fetch SyncToken via update{Product,Customer}SyncToken and recover automatically on the next 3-hour cron tick. The invoice flow uses the cached qbSyncToken directly with no equivalent refresh, so 5010 on invoices remains user-actionable and surfaces. Sync history CSV now exposes the error_code column so IUs can filter past failures by QBO code. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../quickbooks/syncLog/syncErrorNotifier.ts | 90 +++++++++++++++++++ .../api/quickbooks/syncLog/syncLog.service.ts | 88 +++++++++++++++++- 2 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/app/api/quickbooks/syncLog/syncErrorNotifier.ts diff --git a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts new file mode 100644 index 00000000..57ed0f02 --- /dev/null +++ b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts @@ -0,0 +1,90 @@ +import { BaseService } from '@/app/api/core/services/base.service' +import { EntityType, LogStatus } from '@/app/api/core/types/log' +import { + NotificationActions, + NotificationContext, +} from '@/app/api/core/types/notification' +import { NotificationService } from '@/app/api/notification/notification.service' +import { UserActionableErrorCodes } from '@/constant/intuitErrorCode' +import { QBSyncLogSelectSchemaType } from '@/db/schema/qbSyncLogs' +import { getPortalConnection } from '@/db/service/token.service' + +/** + * Looks up the user-actionable notification action for a given QBO error code. + * Returns null when the code is empty, unknown, or refers to a transient/auth + * error handled elsewhere (429, 5xx, invalid_grant, etc.). + */ +export function getActionForErrorCode( + errorCode: string | null | undefined, +): NotificationActions | null { + if (!errorCode) return null + return UserActionableErrorCodes[errorCode] ?? null +} + +/** + * Picks the strongest available identifier for the offending QBO entity so the + * notification body can reference a concrete record (invoice number, item, etc). + */ +export function getEntityKey(log: QBSyncLogSelectSchemaType): string { + return ( + log.quickbooksId || + log.invoiceNumber || + log.qbItemName || + log.copilotPriceId || + log.copilotId || + '' + ) +} + +export class SyncErrorNotifier extends BaseService { + /** + * Dispatches an IU notification for a freshly written FAILED sync log row + * when its errorCode is in the user-actionable registry. One sync_log insert + * = one notification — natural dedup comes from sync_log being created once + * per failed entity. + * + * Errors here are caller-suppressed; a notification failure must not undo + * the sync log write. + */ + async notify(log: QBSyncLogSelectSchemaType): Promise { + if (log.status !== LogStatus.FAILED) return + + const action = getActionForErrorCode(log.errorCode) + if (!action) return + + // 5010 stale-object on customers/items auto-recovers: every retry + // pre-fetches the latest SyncToken via updateProductSyncToken / + // updateCustomerSyncToken before issuing the update, so the next cron tick + // succeeds without IU intervention. Suppress to avoid notification noise. + // The invoice flow has no equivalent pre-fetch (it reads qbSyncToken from + // our DB cache directly), so 5010 on invoices remains user-actionable. + if ( + action === NotificationActions.QB_STALE_OBJECT && + log.entityType !== EntityType.INVOICE + ) { + return + } + + const context: NotificationContext = { + entityType: log.entityType, + eventType: log.eventType, + entityKey: getEntityKey(log), + invoiceNumber: log.invoiceNumber ?? undefined, + customerName: log.customerName ?? undefined, + productName: log.productName ?? undefined, + qbItemName: log.qbItemName ?? undefined, + errorMessage: log.errorMessage ?? undefined, + } + const portal = await getPortalConnection(this.user.workspaceId) + + const notificationService = new NotificationService(this.user) + // Webhook-driven failures have no calling IU. Empty senderId mirrors the + // existing AUTH_RECONNECT pattern (auth.service.ts) where `error.intiatedBy + // ?? ''` is passed to sendNotificationToIU. + await notificationService.sendNotificationToIU( + portal?.intiatedBy || '', + action, + context, + ) + } +} diff --git a/src/app/api/quickbooks/syncLog/syncLog.service.ts b/src/app/api/quickbooks/syncLog/syncLog.service.ts index 2b821146..dc123bd3 100644 --- a/src/app/api/quickbooks/syncLog/syncLog.service.ts +++ b/src/app/api/quickbooks/syncLog/syncLog.service.ts @@ -5,6 +5,8 @@ import { FailedRecordCategoryType, LogStatus, } from '@/app/api/core/types/log' +import { afterIfAvailable } from '@/app/api/core/utils/afterIfAvailable' +import { SyncErrorNotifier } from '@/app/api/quickbooks/syncLog/syncErrorNotifier' import { ConnectionStatus } from '@/db/schema/qbConnectionLogs' import { QBSyncLog, @@ -17,8 +19,10 @@ import { } from '@/db/schema/qbSyncLogs' import { WhereClause } from '@/type/common' import { orderMap } from '@/utils/drizzle' +import CustomLogger from '@/utils/logger' import dayjs from 'dayjs' import { and, eq, isNull, lt, sql } from 'drizzle-orm' +import { captureException } from '@sentry/nextjs' import { json2csv } from 'json-2-csv' export const STALE_PENDING_THRESHOLD_MINUTES = 15 @@ -53,17 +57,87 @@ export class SyncLogService extends BaseService { .returning() console.info('SyncLogService#createQBSyncLog | Sync log complete') + this.scheduleFailureNotification(log) return log } /** - * Creates the sync log + * Schedules a sync-failure notification when a sync log row enters the + * FAILED state. Errors are swallowed and reported to Sentry — a failed + * notification must never propagate up and undo the sync log write. + * + * Hooked at two sites: + * - `createQBSyncLog`, for the rare path that writes a row directly as + * FAILED (legacy / non-claim flows). + * - `updateQBSyncLog`, for the dominant flow today: webhook entry inserts + * a PENDING claim, then a handler updates it to SUCCESS or FAILED. + * + * The update site passes `priorStatus` so we only fire on a *transition* + * into FAILED. FAILED→FAILED retry replays don't re-page IUs. + * + * `flipStalePendingToFailed` writes FAILED rows without an `errorCode`; + * those naturally no-op inside the notifier (`getActionForErrorCode(null)` + * returns null) so we don't need to filter them here. + */ + private scheduleFailureNotification(log: QBSyncLogSelectSchemaType): void { + if (log.status !== LogStatus.FAILED) return + + // Defer dispatch until after the request scope (and any enclosing + // transaction) commits, so the notification can never undo the sync log + // write and never reads an uncommitted row. Errors are caller-suppressed + // — surface to Sentry and continue. + const user = this.user + afterIfAvailable(async () => { + try { + const notifier = new SyncErrorNotifier(user) + await notifier.notify(log) + } catch (error) { + CustomLogger.error({ + message: + 'SyncLogService#scheduleFailureNotification | Notifier failed', + obj: { error, logId: log.id }, + }) + captureException(error, { + tags: { + key: 'syncFailureNotifierError', + portalId: log.portalId, + entityType: log.entityType, + errorCode: log.errorCode ?? 'unknown', + }, + extra: { logId: log.id, errorMessage: log.errorMessage }, + }) + } + }) + } + + /** + * Updates an existing sync log row. When the update transitions the row + * INTO the FAILED state (i.e. its prior status was not FAILED), schedules + * the IU sync-failure notification. Callers that already know the prior + * status (e.g. `updateOrCreateQBSyncLog`) can pass it to skip the lookup; + * otherwise we fetch it before applying the update so retry replays + * (FAILED→FAILED) don't re-notify. */ async updateQBSyncLog( payload: QBSyncLogUpdateSchemaType, conditions: WhereClause, + priorStatus?: LogStatus, ): Promise { const parsedPayload = QBSyncLogUpdateSchema.parse(payload) + // Only consider notifying when this update is *actively* setting status to + // FAILED. Partial updates that don't touch status (e.g. attempt counter + // bumps in `checkAndUpdateAttempt`) leave a FAILED row FAILED — they are + // not transitions and must not re-page IUs. + const settingFailed = parsedPayload.status === LogStatus.FAILED + + if (priorStatus === undefined && settingFailed) { + const existing = await this.db.query.QBSyncLog.findFirst({ + where: conditions, + columns: { status: true }, + }) + priorStatus = existing?.status as LogStatus | undefined + } + const [log] = await this.db .update(QBSyncLog) .set(parsedPayload) @@ -71,6 +145,11 @@ export class SyncLogService extends BaseService { .returning() console.info('SyncLogService#updateQBSyncLog | Sync log updated') + + if (settingFailed && log && priorStatus !== LogStatus.FAILED) { + this.scheduleFailureNotification(log) + } + return log } @@ -134,7 +213,11 @@ export class SyncLogService extends BaseService { } if (existingLog) { - await this.updateQBSyncLog(payload, eq(QBSyncLog.id, existingLog.id)) + await this.updateQBSyncLog( + payload, + eq(QBSyncLog.id, existingLog.id), + existingLog.status as LogStatus, + ) } else { await this.createQBSyncLog(payload) } @@ -317,6 +400,7 @@ export class SyncLogService extends BaseService { ? parseFloat(log.productPrice) / 100 : null, qb_item_name: log.qbItemName, + error_code: log.errorCode, error_message: log.errorMessage, } }) From 92c4aff259dc4055b190feddf4e07ce12cd78998 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Apr 2026 20:48:56 +0545 Subject: [PATCH 05/14] test(OUT-3528): unit coverage for notifier and helper notification.helper.test: AUTH_RECONNECT regression guard, per-action non-empty body assertions, 6240 entityType branching (item-only copy must not leak Customer/Vendor/Employee), 5010 invoice "failure is final" copy. syncErrorNotifier.test: registry lookups (known + unknown codes, empty/null), entity-key fallback ladder, status-not-FAILED skip, unknown-code skip, 5010 suppression on product/customer/payment (parameterized) vs dispatch on invoice, getPortalConnection-null senderId fallback to ''. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notification/notification.helper.test.ts | 155 +++++++++++ .../unit/quickbooks/syncErrorNotifier.test.ts | 253 ++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 test/unit/notification/notification.helper.test.ts create mode 100644 test/unit/quickbooks/syncErrorNotifier.test.ts diff --git a/test/unit/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts new file mode 100644 index 00000000..66e6ebfa --- /dev/null +++ b/test/unit/notification/notification.helper.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest' +import { + NotificationActions, + NotificationContext, +} from '@/app/api/core/types/notification' +import { + getIEmailNotificationDetail, + getInProductNotificationDetail, +} from '@/app/api/notification/notification.helper' + +const SYNC_FAILURE_ACTIONS = [ + NotificationActions.QB_DUPLICATE_DOC_NUMBER, + NotificationActions.QB_DUPLICATE_NAME, + NotificationActions.QB_CLOSED_PERIOD, + NotificationActions.QB_DEPOSITED_TXN_LOCKED, + NotificationActions.QB_INACTIVE_REFERENCE, + NotificationActions.QB_SUBSCRIPTION_INVALID, + NotificationActions.QB_VALIDATION_FAILED, + NotificationActions.QB_STALE_OBJECT, + NotificationActions.QB_TXN_LINK_FAILED, + NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING, +] as const + +describe('getInProductNotificationDetail', () => { + it('returns the existing AUTH_RECONNECT copy unchanged when called without context', () => { + // Backwards-compat guard: the AUTH_RECONNECT call site (auth.service.ts) + // never passes context; that path must keep working byte-for-byte. + const detail = getInProductNotificationDetail( + NotificationActions.AUTH_RECONNECT, + ) + expect(detail.title).toBe('QuickBooks Sync has disconnected') + expect(detail.body).toContain('reconnect') + expect(detail.ctaParams).toEqual({ type: 'reconnect' }) + }) + + it.each(SYNC_FAILURE_ACTIONS)( + 'returns a non-empty title and body for %s', + (action) => { + const detail = getInProductNotificationDetail(action) + expect(detail.title.length).toBeGreaterThan(0) + expect(detail.body.length).toBeGreaterThan(0) + }, + ) + + it('omits the entity reference clause when called without context (AUTH_RECONNECT path)', () => { + const detail = getInProductNotificationDetail( + NotificationActions.QB_DUPLICATE_DOC_NUMBER, + ) + expect(detail.body).not.toMatch(/\(during|ref /) + }) + + it('renders the full action + ref clause for invoice void', () => { + const ctx: NotificationContext = { + entityType: 'invoice', + eventType: 'voided', + invoiceNumber: 'INV-9', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_STALE_OBJECT, + ctx, + ) + expect(detail.body).toContain('during invoice void, ref INV-9') + }) + + it('renders the full action + ref clause for product update with qbItemName', () => { + const ctx: NotificationContext = { + entityType: 'product', + eventType: 'updated', + qbItemName: 'Widget A', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_VALIDATION_FAILED, + ctx, + ) + expect(detail.body).toContain('during product update, ref Widget A') + }) + + it('5010 (invoice-only after suppression) warns that the failure is final', () => { + const ctx: NotificationContext = { + entityType: 'invoice', + eventType: 'updated', + invoiceNumber: 'INV-77', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_STALE_OBJECT, + ctx, + ) + expect(detail.body).toMatch(/voided|deleted/) + expect(detail.body).toMatch(/cannot recover|final/) + }) + + it('6240 product copy frames the conflict as item-vs-item only (no cross-namespace, no 20-variant claim)', () => { + const ctx: NotificationContext = { + entityType: 'product', + eventType: 'created', + qbItemName: 'Widget A', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_DUPLICATE_NAME, + ctx, + ) + expect(detail.body).toContain("item's name") + // QBO Items live in their own namespace — they don't collide with the + // Customer/Vendor/Employee shared name list. Guard against regression. + expect(detail.body).not.toMatch(/Customer|Vendor|Employee/) + expect(detail.body).not.toContain('20 numbered variations') + }) + + it('6240 invoice/customer copy mentions the 20-variant fallback', () => { + const ctx: NotificationContext = { + entityType: 'invoice', + eventType: 'created', + customerName: 'Acme Inc', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_DUPLICATE_NAME, + ctx, + ) + expect(detail.body).toContain('20 numbered variations') + }) +}) + +describe('getIEmailNotificationDetail', () => { + it('returns the existing AUTH_RECONNECT copy unchanged when called without context', () => { + const detail = getIEmailNotificationDetail( + NotificationActions.AUTH_RECONNECT, + ) + expect(detail.subject).toBe('Your QuickBooks Sync has disconnected') + expect(detail.header).toBe('QuickBooks Sync has disconnected') + expect(detail.ctaParams).toEqual({ type: 'reconnect' }) + }) + + it.each(SYNC_FAILURE_ACTIONS)( + 'returns non-empty subject/header/body for %s', + (action) => { + const detail = getIEmailNotificationDetail(action) + expect(detail.subject.length).toBeGreaterThan(0) + expect(detail.header.length).toBeGreaterThan(0) + expect(detail.body.length).toBeGreaterThan(0) + }, + ) + + it('renders the full action + ref clause for customer create', () => { + const ctx: NotificationContext = { + entityType: 'invoice', + eventType: 'created', + customerName: 'Acme Inc', + } + const detail = getIEmailNotificationDetail( + NotificationActions.QB_DUPLICATE_NAME, + ctx, + ) + expect(detail.body).toContain('during invoice creation, ref Acme Inc') + }) +}) diff --git a/test/unit/quickbooks/syncErrorNotifier.test.ts b/test/unit/quickbooks/syncErrorNotifier.test.ts new file mode 100644 index 00000000..f78956c8 --- /dev/null +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { LogStatus } from '@/app/api/core/types/log' +import { NotificationActions } from '@/app/api/core/types/notification' +import { QBSyncLogSelectSchemaType } from '@/db/schema/qbSyncLogs' + +// Stub Sentry + logger before importing the SUT — its transitive imports pull +// in CopilotAPI/IntuitAPI which try to construct real SDK clients at import time. +vi.mock('@sentry/nextjs', () => ({ + withScope: vi.fn(), + captureMessage: vi.fn(), + captureException: vi.fn(), + addBreadcrumb: vi.fn(), +})) + +vi.mock('@/utils/logger', () => ({ + default: { info: vi.fn(), error: vi.fn() }, +})) + +const sendNotificationToIU = vi.fn() +vi.mock('@/app/api/notification/notification.service', () => ({ + // Must be a real constructable class — the SUT calls `new NotificationService(...)`. + NotificationService: class { + sendNotificationToIU = sendNotificationToIU + }, +})) + +vi.mock('@/db', () => ({ + db: {}, + client: {}, +})) + +const getPortalConnectionMock = vi + .fn() + .mockResolvedValue({ intiatedBy: 'iu-1' }) +vi.mock('@/db/service/token.service', () => ({ + getPortalConnection: () => getPortalConnectionMock(), +})) + +import { + SyncErrorNotifier, + getActionForErrorCode, + getEntityKey, +} from '@/app/api/quickbooks/syncLog/syncErrorNotifier' + +const baseLog: QBSyncLogSelectSchemaType = { + id: 'log-1', + portalId: 'portal-1', + entityType: 'invoice' as never, + eventType: 'created' as never, + status: LogStatus.FAILED as never, + syncAt: null, + copilotId: 'copilot-1', + quickbooksId: null, + invoiceNumber: 'INV-001', + amount: null, + remark: null, + customerName: null, + customerEmail: null, + taxAmount: null, + feeAmount: null, + productName: null, + productPrice: null, + qbItemName: null, + copilotPriceId: null, + errorMessage: 'Duplicate Document Number Error', + errorCode: '6140', + category: 'qb_api_error' as never, + attempt: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, +} + +describe('getActionForErrorCode', () => { + it('returns the mapped action for a known QBO error code', () => { + expect(getActionForErrorCode('6140')).toBe( + NotificationActions.QB_DUPLICATE_DOC_NUMBER, + ) + expect(getActionForErrorCode('6210')).toBe( + NotificationActions.QB_CLOSED_PERIOD, + ) + expect(getActionForErrorCode('610')).toBe( + NotificationActions.QB_INACTIVE_REFERENCE, + ) + expect(getActionForErrorCode('2500')).toBe( + NotificationActions.QB_INACTIVE_REFERENCE, + ) + expect(getActionForErrorCode('5010')).toBe( + NotificationActions.QB_STALE_OBJECT, + ) + expect(getActionForErrorCode('620')).toBe( + NotificationActions.QB_TXN_LINK_FAILED, + ) + expect(getActionForErrorCode('2390')).toBe( + NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING, + ) + }) + + it('returns null for unknown / transient / auth codes', () => { + expect(getActionForErrorCode('429')).toBeNull() + expect(getActionForErrorCode('500')).toBeNull() + expect(getActionForErrorCode('120')).toBeNull() + expect(getActionForErrorCode('999')).toBeNull() + }) + + it('returns null for empty / nullish input', () => { + expect(getActionForErrorCode(undefined)).toBeNull() + expect(getActionForErrorCode(null)).toBeNull() + expect(getActionForErrorCode('')).toBeNull() + }) +}) + +describe('getEntityKey', () => { + it('prefers quickbooksId when present', () => { + expect( + getEntityKey({ + ...baseLog, + quickbooksId: 'qb-42', + invoiceNumber: 'INV-001', + }), + ).toBe('qb-42') + }) + + it('falls through to invoiceNumber, then qbItemName, then copilotPriceId, then copilotId', () => { + expect( + getEntityKey({ + ...baseLog, + quickbooksId: null, + invoiceNumber: 'INV-002', + }), + ).toBe('INV-002') + + expect( + getEntityKey({ + ...baseLog, + quickbooksId: null, + invoiceNumber: null, + qbItemName: 'Widget', + }), + ).toBe('Widget') + + expect( + getEntityKey({ + ...baseLog, + quickbooksId: null, + invoiceNumber: null, + qbItemName: null, + copilotPriceId: 'price-x', + }), + ).toBe('price-x') + + expect( + getEntityKey({ + ...baseLog, + quickbooksId: null, + invoiceNumber: null, + qbItemName: null, + copilotPriceId: null, + copilotId: 'co-fallback', + }), + ).toBe('co-fallback') + }) + + it('returns empty string when no identifier is present', () => { + expect( + getEntityKey({ + ...baseLog, + quickbooksId: null, + invoiceNumber: null, + qbItemName: null, + copilotPriceId: null, + copilotId: '', + }), + ).toBe('') + }) +}) + +describe('SyncErrorNotifier#notify', () => { + const user = { + token: 'tok', + workspaceId: 'portal-1', + role: 'iu' as never, + } as never + + beforeEach(() => { + sendNotificationToIU.mockReset() + }) + + it('skips when status is not FAILED', async () => { + const notifier = new SyncErrorNotifier(user) + await notifier.notify({ ...baseLog, status: LogStatus.SUCCESS as never }) + expect(sendNotificationToIU).not.toHaveBeenCalled() + }) + + it('skips when errorCode does not map to a user-actionable action', async () => { + const notifier = new SyncErrorNotifier(user) + await notifier.notify({ ...baseLog, errorCode: '429' }) + expect(sendNotificationToIU).not.toHaveBeenCalled() + }) + + it.each(['product', 'customer', 'payment'] as const)( + 'suppresses 5010 stale-object on %s (auto-recovers via syncToken refresh)', + async (entityType) => { + sendNotificationToIU.mockReset() + const notifier = new SyncErrorNotifier(user) + await notifier.notify({ + ...baseLog, + errorCode: '5010', + entityType: entityType as never, + qbItemName: 'Widget', + }) + expect(sendNotificationToIU).not.toHaveBeenCalled() + }, + ) + + it('falls back to empty senderId when getPortalConnection returns null', async () => { + getPortalConnectionMock.mockResolvedValueOnce(null) + const notifier = new SyncErrorNotifier(user) + await notifier.notify(baseLog) + expect(sendNotificationToIU).toHaveBeenCalledTimes(1) + const [senderId] = sendNotificationToIU.mock.calls[0] + expect(senderId).toBe('') + }) + + it('still dispatches 5010 stale-object on invoices (no auto-recovery)', async () => { + const notifier = new SyncErrorNotifier(user) + await notifier.notify({ + ...baseLog, + errorCode: '5010', + entityType: 'invoice' as never, + }) + expect(sendNotificationToIU).toHaveBeenCalledTimes(1) + const [, action] = sendNotificationToIU.mock.calls[0] + expect(action).toBe(NotificationActions.QB_STALE_OBJECT) + }) + + it('dispatches a notification for a FAILED row with a user-actionable code', async () => { + const notifier = new SyncErrorNotifier(user) + + await notifier.notify(baseLog) + + expect(sendNotificationToIU).toHaveBeenCalledTimes(1) + const [senderId, action, ctx] = sendNotificationToIU.mock.calls[0] + expect(senderId).toBe('iu-1') + expect(action).toBe(NotificationActions.QB_DUPLICATE_DOC_NUMBER) + expect(ctx).toMatchObject({ + entityKey: 'INV-001', + invoiceNumber: 'INV-001', + eventType: 'created', + errorMessage: 'Duplicate Document Number Error', + }) + }) +}) From a85c9901af614efaf38dcdcc569120a6fc5d0ab3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Apr 2026 21:03:57 +0545 Subject: [PATCH 06/14] test(OUT-3528): derive coverage loops from enum + registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both unit test files had hand-maintained action lists that could silently miss new entries. Replaces them with derivations: - syncErrorNotifier.test: it.each over Object.entries(UserActionableErrorCodes) - notification.helper.test: it.each over Object.values(NotificationActions) (no manual filter — AUTH_RECONNECT is asserted explicitly elsewhere and runs through the smoke loop too) A new code in the registry or a new enum value now picks up smoke coverage automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notification/notification.helper.test.ts | 21 ++++-------- .../unit/quickbooks/syncErrorNotifier.test.ts | 33 ++++++------------- 2 files changed, 17 insertions(+), 37 deletions(-) diff --git a/test/unit/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts index 66e6ebfa..cec74959 100644 --- a/test/unit/notification/notification.helper.test.ts +++ b/test/unit/notification/notification.helper.test.ts @@ -8,18 +8,11 @@ import { getInProductNotificationDetail, } from '@/app/api/notification/notification.helper' -const SYNC_FAILURE_ACTIONS = [ - NotificationActions.QB_DUPLICATE_DOC_NUMBER, - NotificationActions.QB_DUPLICATE_NAME, - NotificationActions.QB_CLOSED_PERIOD, - NotificationActions.QB_DEPOSITED_TXN_LOCKED, - NotificationActions.QB_INACTIVE_REFERENCE, - NotificationActions.QB_SUBSCRIPTION_INVALID, - NotificationActions.QB_VALIDATION_FAILED, - NotificationActions.QB_STALE_OBJECT, - NotificationActions.QB_TXN_LINK_FAILED, - NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING, -] as const +// Derived from the enum so a new NotificationActions value is auto-covered +// by the smoke tests below — no risk of silently missing a new template. +// AUTH_RECONNECT also runs through the smoke loop (its dedicated +// byte-fidelity test still runs separately). +const ALL_ACTIONS = Object.values(NotificationActions) describe('getInProductNotificationDetail', () => { it('returns the existing AUTH_RECONNECT copy unchanged when called without context', () => { @@ -33,7 +26,7 @@ describe('getInProductNotificationDetail', () => { expect(detail.ctaParams).toEqual({ type: 'reconnect' }) }) - it.each(SYNC_FAILURE_ACTIONS)( + it.each(ALL_ACTIONS)( 'returns a non-empty title and body for %s', (action) => { const detail = getInProductNotificationDetail(action) @@ -130,7 +123,7 @@ describe('getIEmailNotificationDetail', () => { expect(detail.ctaParams).toEqual({ type: 'reconnect' }) }) - it.each(SYNC_FAILURE_ACTIONS)( + it.each(ALL_ACTIONS)( 'returns non-empty subject/header/body for %s', (action) => { const detail = getIEmailNotificationDetail(action) diff --git a/test/unit/quickbooks/syncErrorNotifier.test.ts b/test/unit/quickbooks/syncErrorNotifier.test.ts index f78956c8..4678f33f 100644 --- a/test/unit/quickbooks/syncErrorNotifier.test.ts +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -41,6 +41,7 @@ import { getActionForErrorCode, getEntityKey, } from '@/app/api/quickbooks/syncLog/syncErrorNotifier' +import { UserActionableErrorCodes } from '@/constant/intuitErrorCode' const baseLog: QBSyncLogSelectSchemaType = { id: 'log-1', @@ -72,29 +73,15 @@ const baseLog: QBSyncLogSelectSchemaType = { } describe('getActionForErrorCode', () => { - it('returns the mapped action for a known QBO error code', () => { - expect(getActionForErrorCode('6140')).toBe( - NotificationActions.QB_DUPLICATE_DOC_NUMBER, - ) - expect(getActionForErrorCode('6210')).toBe( - NotificationActions.QB_CLOSED_PERIOD, - ) - expect(getActionForErrorCode('610')).toBe( - NotificationActions.QB_INACTIVE_REFERENCE, - ) - expect(getActionForErrorCode('2500')).toBe( - NotificationActions.QB_INACTIVE_REFERENCE, - ) - expect(getActionForErrorCode('5010')).toBe( - NotificationActions.QB_STALE_OBJECT, - ) - expect(getActionForErrorCode('620')).toBe( - NotificationActions.QB_TXN_LINK_FAILED, - ) - expect(getActionForErrorCode('2390')).toBe( - NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING, - ) - }) + // Iterating Object.entries makes this test self-extending — any new code + // added to UserActionableErrorCodes is automatically asserted, and any + // mapping change here will fail this test loudly. + it.each(Object.entries(UserActionableErrorCodes))( + 'maps registry code %s to action %s', + (code, expectedAction) => { + expect(getActionForErrorCode(code)).toBe(expectedAction) + }, + ) it('returns null for unknown / transient / auth codes', () => { expect(getActionForErrorCode('429')).toBeNull() From da0b10ac4c5cc67e97b8f7d9d94f8292de144109 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 29 Apr 2026 11:59:28 +0545 Subject: [PATCH 07/14] fix(OUT-3528): guard buildEntityReference against partial context Greptile flagged that buildEntityReference unconditionally interpolated both action and id even when describeAction returned '' (entityType or eventType missing), producing "(during , ref INV-001)". Today's qb_sync_logs schema prevents this on the real path (entityType + eventType are NOT NULL), but the helper is callable from anywhere and a future caller passing partial context would surface broken copy. Conditional segment-building drops a missing dimension instead of emitting an empty placeholder; if both are absent the parenthetical is omitted entirely. Three regression tests added. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/notification/notification.helper.ts | 15 ++++++--- .../notification/notification.helper.test.ts | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index 8381d650..b0cebfca 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -76,9 +76,12 @@ const describeAction = (entityType?: string, eventType?: string): string => { /** * Builds the parenthetical reference clause for a sync-failure notification * body, e.g. " (during invoice void, ref INV-9)". Returns '' when called - * without context (AUTH_RECONNECT path). Sync-failure ctx always carries - * entityType + eventType (NOT NULL on qb_sync_logs) and one of the id fields, - * so we always render the full clause when ctx is present. + * without context (AUTH_RECONNECT path). + * + * Sync-failure ctx normally carries entityType + eventType (NOT NULL on + * qb_sync_logs) and one of the id fields. The segment guards below defend + * against future callers passing partial context — a missing dimension + * drops its segment rather than rendering "(during , ref X)". */ const buildEntityReference = (ctx?: NotificationContext): string => { if (!ctx) return '' @@ -89,7 +92,11 @@ const buildEntityReference = (ctx?: NotificationContext): string => { ctx.productName || ctx.customerName || ctx.entityKey - return ` (during ${action}, ref ${id})` + const segments: string[] = [] + if (action) segments.push(`during ${action}`) + if (id) segments.push(`ref ${id}`) + if (segments.length === 0) return '' + return ` (${segments.join(', ')})` } /** diff --git a/test/unit/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts index cec74959..940fc0f4 100644 --- a/test/unit/notification/notification.helper.test.ts +++ b/test/unit/notification/notification.helper.test.ts @@ -111,6 +111,39 @@ describe('getInProductNotificationDetail', () => { ) expect(detail.body).toContain('20 numbered variations') }) + + it('drops the "during ..." segment when entityType/eventType is missing', () => { + // Partial ctx — schema says this can't happen on real sync log rows, but + // guard against future callers passing context without action dimensions. + const ctx: NotificationContext = { invoiceNumber: 'INV-001' } + const detail = getInProductNotificationDetail( + NotificationActions.QB_DUPLICATE_DOC_NUMBER, + ctx, + ) + expect(detail.body).toContain('(ref INV-001)') + expect(detail.body).not.toMatch(/during ,|during\s*\)/) + }) + + it('drops the "ref ..." segment when no identifier is present', () => { + const ctx: NotificationContext = { + entityType: 'invoice', + eventType: 'created', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_DUPLICATE_DOC_NUMBER, + ctx, + ) + expect(detail.body).toContain('(during invoice creation)') + expect(detail.body).not.toMatch(/ref ,|ref undefined|ref \)/) + }) + + it('omits the parenthetical entirely when ctx has neither action nor id', () => { + const detail = getInProductNotificationDetail( + NotificationActions.QB_DUPLICATE_DOC_NUMBER, + {}, + ) + expect(detail.body).not.toMatch(/\(during|\(ref/) + }) }) describe('getIEmailNotificationDetail', () => { From 31b069306dc5ed43ad58af7e1239e66178f3a869 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 29 Apr 2026 14:57:45 +0545 Subject: [PATCH 08/14] feat(OUT-3528): describe payment.succeeded as "invoice fees creation" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Copilot payment.succeeded webhook triggers a Fee (Purchase entity) creation in QuickBooks — calling it "payment completion" in IU notifications is misleading. Special-case the (payment, succeeded) pair in describeAction so the reference clause reads "during invoice fees creation, ref PUR-…" instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/notification/notification.helper.ts | 8 ++++++++ test/unit/notification/notification.helper.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index b0cebfca..f334729d 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -54,9 +54,17 @@ interface NotificationActionEntry { * "invoice creation" or "product update". Used to tell IUs *which* sync * action triggered the failure (e.g. 5010 during invoice void vs invoice * paid). Falls back to '' when either dimension is missing. + * + * Special-cased pairs describe what we *actually do* in QuickBooks rather + * than the raw Copilot event name — e.g. payment.succeeded triggers a + * Fee/Purchase creation, so we render it as "fee create" instead of + * "payment completion". */ const describeAction = (entityType?: string, eventType?: string): string => { if (!entityType || !eventType) return '' + if (entityType === 'payment' && eventType === 'succeeded') { + return 'invoice fees creation' + } const eventNoun = ( { diff --git a/test/unit/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts index 940fc0f4..0b6a0b36 100644 --- a/test/unit/notification/notification.helper.test.ts +++ b/test/unit/notification/notification.helper.test.ts @@ -68,6 +68,20 @@ describe('getInProductNotificationDetail', () => { expect(detail.body).toContain('during product update, ref Widget A') }) + it('renders payment.succeeded as "invoice fees creation" — matches the QBO operation we actually run', () => { + const ctx: NotificationContext = { + entityType: 'payment', + eventType: 'succeeded', + entityKey: 'PUR-1001', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_DEPOSITED_TXN_LOCKED, + ctx, + ) + expect(detail.body).toContain('during invoice fees creation, ref PUR-1001') + expect(detail.body).not.toContain('payment completion') + }) + it('5010 (invoice-only after suppression) warns that the failure is final', () => { const ctx: NotificationContext = { entityType: 'invoice', From e74f9b6034415759a237b767e2dba81b51ead2a4 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 29 Apr 2026 14:58:13 +0545 Subject: [PATCH 09/14] feat(OUT-3528): add QB_INVALID_ACCOUNT_TYPE for QBO error 6430 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QBO 6430 ("Invalid account type used") fires when a transaction references an account whose AccountType doesn't match what QuickBooks expects — e.g. a Purchase line citing a non-Expense account, or an Item citing a non-Income account. Most plausible cause for us is an account configured under settings (expense/income) being changed to a different type in QuickBooks, or the wrong account picked at setup. Body explains the cause and points the IU at both remediation paths (fix the account type in QuickBooks or change the selection in this app's settings). Auto-coverage from the parameterized helper + notifier tests picks up the new entry without manual additions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/core/types/notification.ts | 1 + src/app/api/notification/notification.helper.ts | 11 +++++++++++ src/constant/intuitErrorCode.ts | 1 + 3 files changed, 13 insertions(+) diff --git a/src/app/api/core/types/notification.ts b/src/app/api/core/types/notification.ts index 3c9fef0c..2ce1d50a 100644 --- a/src/app/api/core/types/notification.ts +++ b/src/app/api/core/types/notification.ts @@ -10,6 +10,7 @@ export enum NotificationActions { QB_STALE_OBJECT = 'qb_stale_object', QB_TXN_LINK_FAILED = 'qb_txn_link_failed', QB_ITEM_INCOME_ACCOUNT_MISSING = 'qb_item_income_account_missing', + QB_INVALID_ACCOUNT_TYPE = 'qb_invalid_account_type', } /** diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index f334729d..ae66037d 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -244,6 +244,17 @@ export const NotificationCopy: Record< emailBody: (ref) => `A sync failed${ref} because a QuickBooks item has no income account assigned. This usually happens when the item was created in QuickBooks without an income account, or the account was removed afterwards. Open Products and Services in QuickBooks, edit the item, and set its income account. The next scheduled retry (within a few hours) will pick it up automatically.`, }, + + [NotificationActions.QB_INVALID_ACCOUNT_TYPE]: { + title: + 'QuickBooks sync failed: account type is invalid for this transaction', + body: (ref) => + `A sync failed${ref} because the QuickBooks account selected for this transaction has the wrong type. This usually happens when an account configured in this app's settings (for example the Expense or Income account) was changed to a different account type in QuickBooks. Update the account in QuickBooks so its type matches what the transaction needs; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, + emailSubject: + 'QuickBooks sync failed: account type is invalid for this transaction', + emailBody: (ref) => + `A sync failed${ref} because the QuickBooks account selected for this transaction has the wrong type. This usually happens when an account configured in this app's settings (for example the Expense or Income account) was changed to a different account type in QuickBooks. Update the account in QuickBooks so its type matches what the transaction needs. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, + }, } export const getInProductNotificationDetail = ( diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index 23b4c360..28ee6add 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -41,4 +41,5 @@ export const UserActionableErrorCodes: Record = { '5010': NotificationActions.QB_STALE_OBJECT, '620': NotificationActions.QB_TXN_LINK_FAILED, '2390': NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING, + '6430': NotificationActions.QB_INVALID_ACCOUNT_TYPE, } From b6d3d0b2c4b0ab8cf90886d63a7816352a89d072 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 19 May 2026 14:27:37 +0545 Subject: [PATCH 10/14] chore(OUT-3528): migration file to add error code in sync logs table --- ...9083948_add_error_code_in_qb_sync_logs.sql | 1 + .../meta/20260519083948_snapshot.json | 1119 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + 3 files changed, 1127 insertions(+) create mode 100644 src/db/migrations/20260519083948_add_error_code_in_qb_sync_logs.sql create mode 100644 src/db/migrations/meta/20260519083948_snapshot.json diff --git a/src/db/migrations/20260519083948_add_error_code_in_qb_sync_logs.sql b/src/db/migrations/20260519083948_add_error_code_in_qb_sync_logs.sql new file mode 100644 index 00000000..08cfbf81 --- /dev/null +++ b/src/db/migrations/20260519083948_add_error_code_in_qb_sync_logs.sql @@ -0,0 +1 @@ +ALTER TABLE "qb_sync_logs" ADD COLUMN "error_code" varchar(50); \ No newline at end of file diff --git a/src/db/migrations/meta/20260519083948_snapshot.json b/src/db/migrations/meta/20260519083948_snapshot.json new file mode 100644 index 00000000..3cc2399e --- /dev/null +++ b/src/db/migrations/meta/20260519083948_snapshot.json @@ -0,0 +1,1119 @@ +{ + "id": "f7d488e4-7c23-4015-b457-0d9e4240a2cf", + "prevId": "ac41313a-2978-42ad-aa32-ea26dc31d10c", + "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 + }, + "error_code": { + "name": "error_code", + "type": "varchar(50)", + "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 b4c13f57..a3eefc20 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1778752426056, "tag": "20260514095346_add_qb_doc_number_column_in_qb_invoice_sync", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1779179988077, + "tag": "20260519083948_add_error_code_in_qb_sync_logs", + "breakpoints": true } ] } \ No newline at end of file From 3216a6a4a0c9cb549b6e79ca340d9a7af1b51c34 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 19 May 2026 17:08:18 +0545 Subject: [PATCH 11/14] fix(OUT-3528): extract QBO Fault code from HttpFetchError postFetcher throws HttpFetchError on any non-2xx, short-circuiting before intuitAPI's assertNotQBFault can run. The HttpFetchError branch of getMessageAndCodeFromError was returning HTTP status (usually 400) as `code`, which then landed in qb_sync_logs.error_code and bypassed the UserActionableErrorCodes registry (keyed by QBO codes like 5010). Surface the Fault.Error[0].code via QBFaultSchema.safeParse on error.body when source is intuit; fall back to error.status when no Fault is present (timeouts, 5xx, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils/error.ts | 16 ++++++- test/unit/utils/error.test.ts | 90 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 test/unit/utils/error.test.ts diff --git a/src/utils/error.ts b/src/utils/error.ts index 5ac0c2e2..00a355bf 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -5,7 +5,7 @@ import { } from '@/app/api/core/exceptions/custom' import { OAuthErrorCodes } from '@/constant/intuitErrorCode' import { CopilotApiError, MessagableError } from '@/type/CopilotApiError' -import { QBFaultErrorSchemaType } from '@/type/dto/intuitAPI.dto' +import { QBFaultErrorSchemaType, QBFaultSchema } from '@/type/dto/intuitAPI.dto' import { refreshTokenExpireMessage } from '@/utils/auth' import { IntuitAPIErrorMessage } from '@/utils/intuitAPI' import httpStatus from 'http-status' @@ -78,7 +78,19 @@ export const getMessageAndCodeFromError = ( : error.url.includes('copilot') ? 'copilot' : 'unknown' - return { message: error.message, code: error.status, source } + // For Intuit, prefer the QBO Fault.Error[0].code (e.g. 5010, 6140) over + // the HTTP status (usually 400). QBFaultSchema already coerces string + // codes to numbers; fall back to error.status when no Fault is present + // (timeouts, generic 5xx, etc.). + let code = error.status + if (source === 'intuit') { + const fault = QBFaultSchema.safeParse(error.body) + const faultCode = fault.success + ? fault.data.Fault.Error[0]?.code + : undefined + if (typeof faultCode === 'number') code = faultCode + } + return { message: error.message, code, source } } else if (error instanceof Error && error.message) { return { message: error.message, code, source: 'unknown' } } else if (isAxiosError(error)) { diff --git a/test/unit/utils/error.test.ts b/test/unit/utils/error.test.ts new file mode 100644 index 00000000..fb3ddf8b --- /dev/null +++ b/test/unit/utils/error.test.ts @@ -0,0 +1,90 @@ +/** + * Unit tests for `getMessageAndCodeFromError` — the helper that normalises + * any thrown error into the `{ message, code, source }` triple that + * webhook/payment/sync handlers write into qb_sync_logs. + * + * Coverage focus is the HttpFetchError → Intuit branch: when QBO returns + * HTTP 400 with a Fault payload (e.g. 5010 stale-object), the function + * must surface the *Fault.Error[0].code* (5010), not the HTTP status (400), + * so the downstream notification registry lookup keys correctly and + * sync_log.error_code stores the QBO code rather than the HTTP envelope. + */ + +import { describe, it, expect } from 'vitest' +import { getMessageAndCodeFromError, HttpFetchError } from '@/utils/error' + +const intuitUrl = + 'https://sandbox-quickbooks.api.intuit.com/v3/company/123/invoice?minorversion=70' + +const makeIntuitHttpError = ( + body: unknown, + message = 'Stale Object Error : ...', +) => + new HttpFetchError({ + status: 400, + statusText: 'Bad Request', + url: intuitUrl, + body, + message, + }) + +describe('getMessageAndCodeFromError — HttpFetchError (Intuit branch)', () => { + it('uses Fault.Error[0].code instead of HTTP status when Fault is present', () => { + const body = { + Fault: { + Error: [ + { + code: '5010', + Message: 'Stale Object Error', + Detail: 'Stale Object Error : You and ...', + }, + ], + type: 'ValidationFault', + }, + } + const result = getMessageAndCodeFromError(makeIntuitHttpError(body)) + expect(result.code).toBe(5010) + expect(result.source).toBe('intuit') + }) + + it('handles numeric Fault code without coercion regressions', () => { + const body = { Fault: { Error: [{ code: 6140 }] } } + const result = getMessageAndCodeFromError(makeIntuitHttpError(body)) + expect(result.code).toBe(6140) + }) + + it('falls back to HTTP status when no Fault payload is present', () => { + const result = getMessageAndCodeFromError( + makeIntuitHttpError(undefined, 'HTTP 500 Internal Server Error'), + ) + expect(result.code).toBe(400) + expect(result.source).toBe('intuit') + }) + + it('falls back to HTTP status when Fault.Error is empty', () => { + const result = getMessageAndCodeFromError( + makeIntuitHttpError({ Fault: { Error: [] } }), + ) + expect(result.code).toBe(400) + }) + + it('falls back to HTTP status when code is non-numeric / missing', () => { + const result = getMessageAndCodeFromError( + makeIntuitHttpError({ Fault: { Error: [{ Message: 'no code' }] } }), + ) + expect(result.code).toBe(400) + }) + + it('keeps HTTP status for non-Intuit hosts even if a Fault-like body sneaks in', () => { + const copilotErr = new HttpFetchError({ + status: 400, + statusText: 'Bad Request', + url: 'https://api.copilot.app/things', + body: { Fault: { Error: [{ code: '5010' }] } }, + message: 'whatever', + }) + const result = getMessageAndCodeFromError(copilotErr) + expect(result.code).toBe(400) + expect(result.source).toBe('copilot') + }) +}) From c3834dd092144cfc96d7ba8e0de337607776b306 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 19 May 2026 17:08:33 +0545 Subject: [PATCH 12/14] refactor(OUT-3528): drop 6140 from notification registry; use QBOErrorCodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6140 Duplicate Document Number is now auto-recovered by the suffix-retry in InvoiceService#webhookInvoiceCreated (OUT-3754), so surfacing an IU notification for it is noise. Remove the QB_DUPLICATE_DOC_NUMBER action and its copy entry. Pathological exit paths (21-char limit, MAX_SUFFIX_ATTEMPTS exhaustion) still throw 6140 but go silent — acceptable per product call. While here, extend QBOErrorCodes to cover every code in UserActionableErrorCodes (12 entries, numeric order) and switch the registry to computed property names so the source-of-truth lives in one place and a typo'd code can't slip through. AccountErrorCodes is unaffected. Tests: helper smoke loops + notifier dispatch fixture now use QB_CLOSED_PERIOD (6210) instead of the removed action. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/core/types/notification.ts | 1 - .../api/notification/notification.helper.ts | 13 +------ src/constant/intuitErrorCode.ts | 38 +++++++++++++------ .../notification/notification.helper.test.ts | 8 ++-- .../unit/quickbooks/syncErrorNotifier.test.ts | 13 ++++--- 5 files changed, 40 insertions(+), 33 deletions(-) diff --git a/src/app/api/core/types/notification.ts b/src/app/api/core/types/notification.ts index 2ce1d50a..83d6f080 100644 --- a/src/app/api/core/types/notification.ts +++ b/src/app/api/core/types/notification.ts @@ -1,6 +1,5 @@ export enum NotificationActions { AUTH_RECONNECT = 'auth_reconnect', - QB_DUPLICATE_DOC_NUMBER = 'qb_duplicate_doc_number', QB_DUPLICATE_NAME = 'qb_duplicate_name', QB_CLOSED_PERIOD = 'qb_closed_period', QB_DEPOSITED_TXN_LOCKED = 'qb_deposited_txn_locked', diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index ae66037d..82493e3a 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -134,15 +134,6 @@ export const NotificationCopy: Record< ctaParams: { type: AuthStatus.RECONNECT }, }, - [NotificationActions.QB_DUPLICATE_DOC_NUMBER]: { - title: 'QuickBooks sync failed: duplicate document number', - body: (ref) => - `A sync failed${ref} because another invoice in QuickBooks already uses this document number. This often happens when "Custom transaction numbers" is enabled and the same number was reused, or an invoice with that number was created manually in QuickBooks. Change the document number on the conflicting invoice in QuickBooks; we re-attempt failed syncs every few hours, so the next run should pick it up. ${MANUAL_EDIT_NOTE}`, - emailSubject: 'QuickBooks sync failed: duplicate document number', - emailBody: (ref) => - `A sync failed${ref} because another invoice in QuickBooks already uses this document number. This often happens when "Custom transaction numbers" is enabled in QuickBooks and the same number was reused, or an invoice with that number was created manually. Change the document number on the conflicting invoice in QuickBooks. We re-attempt failed syncs automatically every few hours, so the next run should pick it up — no further action needed in this app. ${MANUAL_EDIT_NOTE}`, - }, - // 6240 lives in two QBO namespaces: Items have their own list, while // Customer/Vendor/Employee share one. The body branches on entityType // because the fix is namespace-specific. @@ -162,10 +153,10 @@ export const NotificationCopy: Record< [NotificationActions.QB_CLOSED_PERIOD]: { title: 'QuickBooks sync failed: accounting period is closed', body: (ref) => - `A sync failed${ref} because the transaction date falls in a closed accounting period. This usually happens after a fiscal year close or when a closing date was set in Settings → Account and Settings → Advanced in QuickBooks. Reopen the period or move the closing date in QuickBooks; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, + `A sync failed${ref} because the transaction date falls in a closed accounting period. This usually happens after a fiscal year close or when a closing date was set in Quickbooks under Settings → Account and Settings → Advanced. Reopen the period or move the closing date in QuickBooks; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, emailSubject: 'QuickBooks sync failed: accounting period is closed', emailBody: (ref) => - `A sync failed${ref} because the transaction date falls in a closed accounting period in QuickBooks. This usually happens after a fiscal year close or when a closing date was set in Settings → Account and Settings → Advanced. Reopen the period or move the closing date in QuickBooks. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, + `A sync failed${ref} because the transaction date falls in a closed accounting period in QuickBooks. This usually happens after a fiscal year close or when a closing date was set in Quickbooks under Settings → Account and Settings → Advanced. Reopen the period or move the closing date in QuickBooks. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, }, [NotificationActions.QB_DEPOSITED_TXN_LOCKED]: { diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index 28ee6add..f78fe985 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -2,10 +2,18 @@ import { NotificationActions } from '@/app/api/core/types/notification' // Doc: https://developer.intuit.com/app/developer/qbo/docs/develop/troubleshooting/error-codes export const QBOErrorCodes = { + OBJECT_NOT_FOUND: 610, + TXN_LINK_FAILED: 620, + ITEM_INCOME_ACCOUNT_MISSING: 2390, + INVALID_REFERENCE_ID: 2500, + STALE_OBJECT: 5010, BUSINESS_VALIDATION: 6000, DUPLICATE_DOC_NUMBER: 6140, ACCOUNT_SUSPENDED: 6190, + CLOSED_PERIOD: 6210, DUPLICATE_NAME_EXISTS: 6240, // customer/vendor/employee name collision + INVALID_ACCOUNT_TYPE: 6430, + DEPOSITED_TXN_LOCKED: 6540, } as const export type QBOErrorCode = (typeof QBOErrorCodes)[keyof typeof QBOErrorCodes] @@ -30,16 +38,22 @@ export const OAuthErrorCodes = { * payloads and we persist it as varchar in qb_sync_logs.error_code. */ export const UserActionableErrorCodes: Record = { - '6140': NotificationActions.QB_DUPLICATE_DOC_NUMBER, - '6240': NotificationActions.QB_DUPLICATE_NAME, - '6210': NotificationActions.QB_CLOSED_PERIOD, - '6540': NotificationActions.QB_DEPOSITED_TXN_LOCKED, - '610': NotificationActions.QB_INACTIVE_REFERENCE, - '2500': NotificationActions.QB_INACTIVE_REFERENCE, - '6190': NotificationActions.QB_SUBSCRIPTION_INVALID, - '6000': NotificationActions.QB_VALIDATION_FAILED, - '5010': NotificationActions.QB_STALE_OBJECT, - '620': NotificationActions.QB_TXN_LINK_FAILED, - '2390': NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING, - '6430': NotificationActions.QB_INVALID_ACCOUNT_TYPE, + // QBOErrorCodes.DUPLICATE_DOC_NUMBER intentionally omitted — see + // InvoiceService#webhookInvoiceCreated suffix-retry (OUT-3754). + [QBOErrorCodes.OBJECT_NOT_FOUND]: NotificationActions.QB_INACTIVE_REFERENCE, + [QBOErrorCodes.TXN_LINK_FAILED]: NotificationActions.QB_TXN_LINK_FAILED, + [QBOErrorCodes.ITEM_INCOME_ACCOUNT_MISSING]: + NotificationActions.QB_ITEM_INCOME_ACCOUNT_MISSING, + [QBOErrorCodes.INVALID_REFERENCE_ID]: + NotificationActions.QB_INACTIVE_REFERENCE, + [QBOErrorCodes.STALE_OBJECT]: NotificationActions.QB_STALE_OBJECT, + [QBOErrorCodes.BUSINESS_VALIDATION]: NotificationActions.QB_VALIDATION_FAILED, + [QBOErrorCodes.ACCOUNT_SUSPENDED]: + NotificationActions.QB_SUBSCRIPTION_INVALID, + [QBOErrorCodes.CLOSED_PERIOD]: NotificationActions.QB_CLOSED_PERIOD, + [QBOErrorCodes.DUPLICATE_NAME_EXISTS]: NotificationActions.QB_DUPLICATE_NAME, + [QBOErrorCodes.INVALID_ACCOUNT_TYPE]: + NotificationActions.QB_INVALID_ACCOUNT_TYPE, + [QBOErrorCodes.DEPOSITED_TXN_LOCKED]: + NotificationActions.QB_DEPOSITED_TXN_LOCKED, } diff --git a/test/unit/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts index 0b6a0b36..580ce839 100644 --- a/test/unit/notification/notification.helper.test.ts +++ b/test/unit/notification/notification.helper.test.ts @@ -37,7 +37,7 @@ describe('getInProductNotificationDetail', () => { it('omits the entity reference clause when called without context (AUTH_RECONNECT path)', () => { const detail = getInProductNotificationDetail( - NotificationActions.QB_DUPLICATE_DOC_NUMBER, + NotificationActions.QB_CLOSED_PERIOD, ) expect(detail.body).not.toMatch(/\(during|ref /) }) @@ -131,7 +131,7 @@ describe('getInProductNotificationDetail', () => { // guard against future callers passing context without action dimensions. const ctx: NotificationContext = { invoiceNumber: 'INV-001' } const detail = getInProductNotificationDetail( - NotificationActions.QB_DUPLICATE_DOC_NUMBER, + NotificationActions.QB_CLOSED_PERIOD, ctx, ) expect(detail.body).toContain('(ref INV-001)') @@ -144,7 +144,7 @@ describe('getInProductNotificationDetail', () => { eventType: 'created', } const detail = getInProductNotificationDetail( - NotificationActions.QB_DUPLICATE_DOC_NUMBER, + NotificationActions.QB_CLOSED_PERIOD, ctx, ) expect(detail.body).toContain('(during invoice creation)') @@ -153,7 +153,7 @@ describe('getInProductNotificationDetail', () => { it('omits the parenthetical entirely when ctx has neither action nor id', () => { const detail = getInProductNotificationDetail( - NotificationActions.QB_DUPLICATE_DOC_NUMBER, + NotificationActions.QB_CLOSED_PERIOD, {}, ) expect(detail.body).not.toMatch(/\(during|\(ref/) diff --git a/test/unit/quickbooks/syncErrorNotifier.test.ts b/test/unit/quickbooks/syncErrorNotifier.test.ts index 4678f33f..fc1cec5c 100644 --- a/test/unit/quickbooks/syncErrorNotifier.test.ts +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -41,7 +41,10 @@ import { getActionForErrorCode, getEntityKey, } from '@/app/api/quickbooks/syncLog/syncErrorNotifier' -import { UserActionableErrorCodes } from '@/constant/intuitErrorCode' +import { + QBOErrorCodes, + UserActionableErrorCodes, +} from '@/constant/intuitErrorCode' const baseLog: QBSyncLogSelectSchemaType = { id: 'log-1', @@ -63,8 +66,8 @@ const baseLog: QBSyncLogSelectSchemaType = { productPrice: null, qbItemName: null, copilotPriceId: null, - errorMessage: 'Duplicate Document Number Error', - errorCode: '6140', + errorMessage: 'Closed accounting period', + errorCode: String(QBOErrorCodes.CLOSED_PERIOD), category: 'qb_api_error' as never, attempt: 0, createdAt: new Date(), @@ -229,12 +232,12 @@ describe('SyncErrorNotifier#notify', () => { expect(sendNotificationToIU).toHaveBeenCalledTimes(1) const [senderId, action, ctx] = sendNotificationToIU.mock.calls[0] expect(senderId).toBe('iu-1') - expect(action).toBe(NotificationActions.QB_DUPLICATE_DOC_NUMBER) + expect(action).toBe(NotificationActions.QB_CLOSED_PERIOD) expect(ctx).toMatchObject({ entityKey: 'INV-001', invoiceNumber: 'INV-001', eventType: 'created', - errorMessage: 'Duplicate Document Number Error', + errorMessage: 'Closed accounting period', }) }) }) From 6b0a48bcf994cb4949568023a9337bd03db34c87 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 19 May 2026 17:16:06 +0545 Subject: [PATCH 13/14] =?UTF-8?q?fix(OUT-3528):=20casing=20typo=20Quickboo?= =?UTF-8?q?ks=20=E2=86=92=20QuickBooks=20in=20CLOSED=5FPERIOD=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two IU-facing strings in the QB_CLOSED_PERIOD entry used the lowercase "Quickbooks"; surrounding copy uses "QuickBooks" everywhere else. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/notification/notification.helper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index 82493e3a..a1e4d4cc 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -153,10 +153,10 @@ export const NotificationCopy: Record< [NotificationActions.QB_CLOSED_PERIOD]: { title: 'QuickBooks sync failed: accounting period is closed', body: (ref) => - `A sync failed${ref} because the transaction date falls in a closed accounting period. This usually happens after a fiscal year close or when a closing date was set in Quickbooks under Settings → Account and Settings → Advanced. Reopen the period or move the closing date in QuickBooks; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, + `A sync failed${ref} because the transaction date falls in a closed accounting period. This usually happens after a fiscal year close or when a closing date was set in QuickBooks under Settings → Account and Settings → Advanced. Reopen the period or move the closing date in QuickBooks; the next scheduled retry will pick it up. ${MANUAL_EDIT_NOTE}`, emailSubject: 'QuickBooks sync failed: accounting period is closed', emailBody: (ref) => - `A sync failed${ref} because the transaction date falls in a closed accounting period in QuickBooks. This usually happens after a fiscal year close or when a closing date was set in Quickbooks under Settings → Account and Settings → Advanced. Reopen the period or move the closing date in QuickBooks. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, + `A sync failed${ref} because the transaction date falls in a closed accounting period in QuickBooks. This usually happens after a fiscal year close or when a closing date was set in QuickBooks under Settings → Account and Settings → Advanced. Reopen the period or move the closing date in QuickBooks. The next scheduled retry (within a few hours) will pick it up automatically. ${MANUAL_EDIT_NOTE}`, }, [NotificationActions.QB_DEPOSITED_TXN_LOCKED]: { From 0b4f64dc172726b511983db1614cd922da41c0de Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 19 May 2026 17:50:21 +0545 Subject: [PATCH 14/14] fix(OUT-3528): narrow 5010 suppression to PRODUCT only Greptile flagged that `entityType !== INVOICE` silently swallowed 5010 on PAYMENT (and any future non-invoice entity) without an auto-recovery path. PRODUCT is the only entityType with an actual SyncToken refresh (updateProductSyncToken); flip the guard to an explicit allow-list so INVOICE and PAYMENT 5010s both surface to IUs. Tests: suppression now asserts only on product; dispatch asserts on both invoice and payment via it.each. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../quickbooks/syncLog/syncErrorNotifier.ts | 11 ++--- .../unit/quickbooks/syncErrorNotifier.test.ts | 49 +++++++++---------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts index 57ed0f02..2682d76a 100644 --- a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts +++ b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts @@ -52,15 +52,12 @@ export class SyncErrorNotifier extends BaseService { const action = getActionForErrorCode(log.errorCode) if (!action) return - // 5010 stale-object on customers/items auto-recovers: every retry - // pre-fetches the latest SyncToken via updateProductSyncToken / - // updateCustomerSyncToken before issuing the update, so the next cron tick - // succeeds without IU intervention. Suppress to avoid notification noise. - // The invoice flow has no equivalent pre-fetch (it reads qbSyncToken from - // our DB cache directly), so 5010 on invoices remains user-actionable. + // PRODUCT 5010 auto-recovers via updateProductSyncToken on the next + // cron tick. INVOICE/PAYMENT have no equivalent refresh, so their + // 5010s stay user-actionable. if ( action === NotificationActions.QB_STALE_OBJECT && - log.entityType !== EntityType.INVOICE + log.entityType === EntityType.PRODUCT ) { return } diff --git a/test/unit/quickbooks/syncErrorNotifier.test.ts b/test/unit/quickbooks/syncErrorNotifier.test.ts index fc1cec5c..deb6c752 100644 --- a/test/unit/quickbooks/syncErrorNotifier.test.ts +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -188,20 +188,16 @@ describe('SyncErrorNotifier#notify', () => { expect(sendNotificationToIU).not.toHaveBeenCalled() }) - it.each(['product', 'customer', 'payment'] as const)( - 'suppresses 5010 stale-object on %s (auto-recovers via syncToken refresh)', - async (entityType) => { - sendNotificationToIU.mockReset() - const notifier = new SyncErrorNotifier(user) - await notifier.notify({ - ...baseLog, - errorCode: '5010', - entityType: entityType as never, - qbItemName: 'Widget', - }) - expect(sendNotificationToIU).not.toHaveBeenCalled() - }, - ) + it('suppresses 5010 stale-object on product (auto-recovers via updateProductSyncToken)', async () => { + const notifier = new SyncErrorNotifier(user) + await notifier.notify({ + ...baseLog, + errorCode: '5010', + entityType: 'product' as never, + qbItemName: 'Widget', + }) + expect(sendNotificationToIU).not.toHaveBeenCalled() + }) it('falls back to empty senderId when getPortalConnection returns null', async () => { getPortalConnectionMock.mockResolvedValueOnce(null) @@ -212,17 +208,20 @@ describe('SyncErrorNotifier#notify', () => { expect(senderId).toBe('') }) - it('still dispatches 5010 stale-object on invoices (no auto-recovery)', async () => { - const notifier = new SyncErrorNotifier(user) - await notifier.notify({ - ...baseLog, - errorCode: '5010', - entityType: 'invoice' as never, - }) - expect(sendNotificationToIU).toHaveBeenCalledTimes(1) - const [, action] = sendNotificationToIU.mock.calls[0] - expect(action).toBe(NotificationActions.QB_STALE_OBJECT) - }) + it.each(['invoice', 'payment'] as const)( + 'dispatches 5010 stale-object on %s (no auto-recovery)', + async (entityType) => { + const notifier = new SyncErrorNotifier(user) + await notifier.notify({ + ...baseLog, + errorCode: '5010', + entityType: entityType as never, + }) + expect(sendNotificationToIU).toHaveBeenCalledTimes(1) + const [, action] = sendNotificationToIU.mock.calls[0] + expect(action).toBe(NotificationActions.QB_STALE_OBJECT) + }, + ) it('dispatches a notification for a FAILED row with a user-actionable code', async () => { const notifier = new SyncErrorNotifier(user)