diff --git a/src/app/api/core/types/notification.ts b/src/app/api/core/types/notification.ts index deaf619b..83d6f080 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_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', + QB_INVALID_ACCOUNT_TYPE = 'qb_invalid_account_type', +} + +/** + * 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..a1e4d4cc 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -1,38 +1,277 @@ 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. + * + * 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 = + ( + { + 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 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 '' + const action = describeAction(ctx.entityType, ctx.eventType) + const id = + ctx.invoiceNumber || + ctx.qbItemName || + ctx.productName || + ctx.customerName || + ctx.entityKey + const segments: string[] = [] + if (action) segments.push(`during ${action}`) + if (id) segments.push(`ref ${id}`) + if (segments.length === 0) return '' + return ` (${segments.join(', ')})` +} + +/** + * 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 }, + }, + + // 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 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}`, + }, + + [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.`, + }, + + [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 = ( + 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/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/syncLog/syncErrorNotifier.ts b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts new file mode 100644 index 00000000..2682d76a --- /dev/null +++ b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts @@ -0,0 +1,87 @@ +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 + + // 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.PRODUCT + ) { + 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, } }) 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/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index db1718a8..f78fe985 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -1,9 +1,19 @@ +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] @@ -16,3 +26,34 @@ 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 = { + // 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/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 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(), diff --git a/src/utils/error.ts b/src/utils/error.ts index 72c7a51c..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' @@ -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)) { @@ -75,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/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts new file mode 100644 index 00000000..580ce839 --- /dev/null +++ b/test/unit/notification/notification.helper.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest' +import { + NotificationActions, + NotificationContext, +} from '@/app/api/core/types/notification' +import { + getIEmailNotificationDetail, + getInProductNotificationDetail, +} from '@/app/api/notification/notification.helper' + +// 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', () => { + // 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(ALL_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_CLOSED_PERIOD, + ) + 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('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', + 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') + }) + + 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_CLOSED_PERIOD, + 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_CLOSED_PERIOD, + 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_CLOSED_PERIOD, + {}, + ) + expect(detail.body).not.toMatch(/\(during|\(ref/) + }) +}) + +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(ALL_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..deb6c752 --- /dev/null +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -0,0 +1,242 @@ +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' +import { + QBOErrorCodes, + UserActionableErrorCodes, +} from '@/constant/intuitErrorCode' + +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: 'Closed accounting period', + errorCode: String(QBOErrorCodes.CLOSED_PERIOD), + category: 'qb_api_error' as never, + attempt: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, +} + +describe('getActionForErrorCode', () => { + // 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() + 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('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) + const notifier = new SyncErrorNotifier(user) + await notifier.notify(baseLog) + expect(sendNotificationToIU).toHaveBeenCalledTimes(1) + const [senderId] = sendNotificationToIU.mock.calls[0] + expect(senderId).toBe('') + }) + + 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) + + 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_CLOSED_PERIOD) + expect(ctx).toMatchObject({ + entityKey: 'INV-001', + invoiceNumber: 'INV-001', + eventType: 'created', + errorMessage: 'Closed accounting period', + }) + }) +}) 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') + }) +})