Skip to content

OUT-3528: notify IUs when QBO sync fails with manual-fix errors#238

Open
SandipBajracharya wants to merge 9 commits intopreviewfrom
OUT-3528
Open

OUT-3528: notify IUs when QBO sync fails with manual-fix errors#238
SandipBajracharya wants to merge 9 commits intopreviewfrom
OUT-3528

Conversation

@SandipBajracharya
Copy link
Copy Markdown
Collaborator

@SandipBajracharya SandipBajracharya commented Apr 28, 2026

Summary

When QBO returns an error caused by a manual change in QuickBooks that our app can't auto-resolve (e.g. 6140 duplicate doc number, 6210 closed accounting period, 6540 deposited transaction locked), surface an in-product + email notification to the workspace's IUs via the existing Copilot notification path. Until now these failures landed silently in qb_sync_logs with no IU-facing surfacing.

  • 11 manual-fix QBO codes routed to tailored notification copy: 6140, 6240, 6210, 6540, 610, 2500, 6190, 6000, 5010, 620, 2390.
  • Single dispatch hook at the sync log write site — fires on the row's transition into FAILED (PENDING/SUCCESS → FAILED). Retries don't re-page; partial updates that don't touch status short-circuit.
  • 5010 on products / customers is suppressed (auto-recovers via updateProductSyncToken / updateCustomerSyncToken on the next 3-hour cron tick). 5010 on invoices remains user-actionable since the invoice flow uses cached qbSyncToken directly with no equivalent refresh.
  • Sync history CSV now exposes the error_code column so IUs can filter past failures by QBO code.

Notification copy

A supervisor-facing recap of every body string is in docs/OUT-3528-notification-recap.md (local-only — docs/ is gitignored). Awaiting product / supervisor sign-off on copy before merge.

How it works

  1. Schema — new nullable qb_sync_logs.error_code varchar(50). Zero-downtime migration.
  2. PlumbinggetMessageAndCodeFromError returns the QBO Fault.Error.code (e.g. 6140) instead of the HTTP status when the error originates from QBO. Each FAILED-path call site (webhook, payment, sync resync) threads the code into createQBSyncLog / updateQBSyncLog.
  3. RegistryUserActionableErrorCodes: Record<string, NotificationActions> in src/constant/intuitErrorCode.ts.
  4. CopyNotificationCopy registry in src/app/api/notification/notification.helper.ts holds title + in-product body + email subject/header/body per action. IU_RECIPIENT_ACTIONS is derived from the registry keys.
  5. DispatchSyncErrorNotifier is fired from SyncLogService.scheduleFailureNotification via afterIfAvailable so the request scope (and any enclosing transaction) commits first; notifier failures are captured to Sentry and never undo the sync log write.

Commits

  1. feat(OUT-3528): add error_code column to qb_sync_logs
  2. feat(OUT-3528): plumb QBO error codes into sync log writes
  3. feat(OUT-3528): IU notifications for user-actionable QBO errors
  4. feat(OUT-3528): dispatch sync-failure notifications via SyncErrorNotifier
  5. test(OUT-3528): unit coverage for notifier and helper

Test plan

  • yarn tsc --noEmit clean
  • yarn lint:check no new errors / warnings on touched files
  • yarn vitest run --project unit — 94/94 passing
  • Integration tests (vitest run --project integration) — needs Docker for testcontainers/postgresql, deferred to CI / a Docker-capable env
  • Manual sandbox verification — trigger a 6140 in QBO sandbox by setting "Custom transaction numbers" preference and pushing two invoices with the same DocNumber; confirm Copilot bell + email arrive once and don't repeat on retry

Reviewer notes

  • Bodies for 5010 and 620 intentionally avoid prescribing specific fixes (e.g. "undo the void") because QBO's stale-object / link-failed errors don't tell us what changed in QuickBooks — only that our cached state is incompatible. The copy lists possibilities and asks the IU to verify the current state.
  • 6240 branches on entityType: items live in a separate QBO namespace from Customer/Vendor/Employee, so the item-flow copy doesn't claim cross-namespace collision.
  • AUTH_RECONNECT flow is pre-existing and untouched; only integrated into the new derived IU_RECIPIENT_ACTIONS set.

🤖 Generated with Claude Code

SandipBajracharya and others added 5 commits April 28, 2026 20:47
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…fier

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@linear-code
Copy link
Copy Markdown

linear-code Bot commented Apr 28, 2026

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
quickbooks-sync Building Building Apr 29, 2026 9:15am
quickbooks-sync (dev) Ready Ready Preview, Comment Apr 29, 2026 9:15am

Request Review

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) <noreply@anthropic.com>
@SandipBajracharya SandipBajracharya changed the title feat(OUT-3528): notify IUs when QBO sync fails with manual-fix errors OUT-3528: notify IUs when QBO sync fails with manual-fix errors Apr 28, 2026
@SandipBajracharya SandipBajracharya marked this pull request as ready for review April 29, 2026 04:41
@SandipBajracharya
Copy link
Copy Markdown
Collaborator Author

@greptileai

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 29, 2026

Greptile Summary

This PR surfaces QBO sync failures to workspace IUs via in-product and email notifications, routing 11 user-actionable QBO fault codes through a new SyncErrorNotifier dispatched via afterIfAvailable after each FAILED sync log write. The schema, plumbing, registry, copy, and dispatch mechanics are all solid, but two P1 issues warrant attention before merge:

  • Notification flood for systemic errors: QB_SUBSCRIPTION_INVALID (6190) and QB_CLOSED_PERIOD (6210) dispatch one notification per failing entity. A workspace with many queued syncs during a subscription lapse could deliver dozens of identical notifications to each IU. The copy already uses workspace-level language ("Syncs are failing…"), but the dispatch model is per-entity with no workspace-level deduplication.
  • IntuitErrorType casing: Codecode rename in the type definition is untested against a live sandbox fault payload; if QBO delivers the uppercase Code field, Number(undefined) silently becomes NaN, making error_code useless and suppressing all notifications.

Confidence Score: 3/5

Two P1 issues should be resolved before merge: potential notification flooding for workspace-level errors and unverified IntuitErrorType field casing that could silently break all error-code-based notifications.

Two independent P1 findings: (1) the per-entity dispatch model for 6190/6210 could flood IUs during systemic outages, and (2) the Code→code rename in IntuitErrorType is unvalidated against live QBO payloads — if wrong, all error codes silently become NaN and no notifications fire. Either alone would cap at 4; both together with the TOCTOU race lower it to 3.

src/utils/error.ts (casing correctness), src/app/api/quickbooks/syncLog/syncErrorNotifier.ts (flood/dedup for systemic codes), src/app/api/quickbooks/syncLog/syncLog.service.ts (TOCTOU on priorStatus read)

Important Files Changed

Filename Overview
src/app/api/quickbooks/syncLog/syncErrorNotifier.ts New dispatcher that routes FAILED log entries to IU notifications; P1 flooding risk for workspace-level errors (6190, 6210) where every failing entity fires its own notification
src/app/api/quickbooks/syncLog/syncLog.service.ts Adds scheduleFailureNotification hook at both create and update sites; the findFirst/update split introduces a TOCTOU window where concurrent workers could double-notify
src/utils/error.ts Renames IntuitErrorType fields Code→code and Element→element and threads the Intuit fault code through ErrorMessageAndCode.code; casing correctness against live QBO payloads is unverified
src/app/api/notification/notification.helper.ts Full refactor to a NotificationCopy registry with per-action body builders; buildEntityReference can produce malformed output when action string is empty (partial context)
src/app/api/notification/notification.service.ts IU_RECIPIENT_ACTIONS set derived from NotificationCopy keys; adds NotificationContext threading and Sentry instrumentation for dispatch failures
src/constant/intuitErrorCode.ts Adds UserActionableErrorCodes registry mapping 11 QBO fault codes to NotificationActions; keyed by string to match varchar storage and JSON payload type
src/db/schema/qbSyncLogs.ts Adds nullable errorCode varchar(50) column; schema matches migration correctly
src/app/api/quickbooks/webhook/webhook.service.ts Threads errorCode from getMessageAndCodeFromError into sync log writes across 4 webhook paths; straightforward plumbing

Sequence Diagram

sequenceDiagram
    participant WH as WebhookService / SyncService / PaymentService
    participant SLS as SyncLogService
    participant AIA as afterIfAvailable
    participant SEN as SyncErrorNotifier
    participant NS as NotificationService
    participant CP as CopilotAPI

    WH->>SLS: updateQBSyncLog({status: FAILED, errorCode: "6140"}, conditions, priorStatus)
    SLS->>SLS: findFirst(priorStatus) if unknown
    SLS->>SLS: db.update(QBSyncLog)
    SLS->>SLS: scheduleFailureNotification(log) if PENDING→FAILED
    SLS->>AIA: defer async callback
    AIA-->>SEN: new SyncErrorNotifier(user).notify(log)
    SEN->>SEN: getActionForErrorCode("6140") → QB_DUPLICATE_DOC_NUMBER
    SEN->>SEN: suppress if 5010 + entityType != invoice
    SEN->>SEN: buildContext(log)
    SEN->>SEN: getPortalConnection(workspaceId)
    SEN->>NS: sendNotificationToIU(senderId, action, context)
    NS->>CP: getInternalUsers()
    CP-->>NS: IU list
    NS->>CP: createNotification(IU, inProduct, email)
Loading

Comments Outside Diff (2)

  1. src/app/api/quickbooks/syncLog/syncErrorNotifier.ts, line 534-574 (link)

    P1 Notification flood for workspace-level errors (QB_SUBSCRIPTION_INVALID)

    scheduleFailureNotification fires once per entity that transitions to FAILED. For error 6190 (QB_SUBSCRIPTION_INVALID), every pending entity sync will fail simultaneously when a subscription lapses, dispatching one notification per entity to all IUs. A workspace with 50 queued syncs sends 50 identical "subscription issue" messages per IU — the exact opposite of the copy's workspace-level framing ("Syncs are failing…so every sync attempt for this portal will fail").

    The deduplication in updateQBSyncLog (FAILED→FAILED no-op) prevents retry spam but does nothing about multiple distinct entities failing at the same time. An explicit check before dispatch — comparing the count of FAILED logs with the same portalId + errorCode and skipping if a notification was already sent recently — would address this. The same issue applies to QB_CLOSED_PERIOD (6210) when a fiscal close affects many transactions simultaneously.

  2. src/app/api/quickbooks/syncLog/syncLog.service.ts, line 678-684 (link)

    P2 TOCTOU race on priorStatus read

    The findFirst and the subsequent update run in two separate queries with no locking. If two concurrent workers (e.g., two parallel webhook deliveries for the same entity) both read PENDING before either write commits, both see priorStatus !== FAILED and both call scheduleFailureNotification, firing duplicate notifications to IUs.

    Wrapping the read + update in a single UPDATE … WHERE status != 'failed' RETURNING (or passing FOR UPDATE in the select) would make this atomic without needing application-level coordination.

Reviews (1): Last reviewed commit: "test(OUT-3528): derive coverage loops fr..." | Re-trigger Greptile

Comment thread src/utils/error.ts
Comment on lines 13 to +17
export type IntuitErrorType = {
Message: string
Detail: string
Code: string
Element?: string
code: string
element?: string
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 IntuitErrorType casing change may silently miss the fault code

The PR renames Codecode and Elementelement on IntuitErrorType. If the actual QBO JSON Fault payload delivers these fields with their original casing (Code, Element), then (error.errors?.[0] as IntuitErrorType).code evaluates to undefined, Number(undefined) produces NaN, and NaN.toString() is stored as "NaN" in error_code — matching nothing in UserActionableErrorCodes and producing a silently broken notification path.

Since Message and Detail remain capitalised (matching QBO's documented schema), it is worth verifying with a real sandbox fault payload that QBO uses lowercase code / element before merging.

Copy link
Copy Markdown
Collaborator Author

@SandipBajracharya SandipBajracharya Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error payload from intuit has code and element in lowercase.

   "Fault":
   {
      "Error": [
      {
         "Message": "<message>",
         "Detail": "<detail>",
         "code": "<code>",
         "element": ""
      }],
      "type": "ValidationFault"
   },
   "time": "2017-09-29T09:17:31.892-07:00"
}```

Comment thread src/app/api/notification/notification.helper.ts
return await copilot.getInternalUsers()
default:
return null
if (IU_RECIPIENT_ACTIONS.has(action)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SandipBajracharya I don't understand this change. Previously it'd have returned null for NotificationActions.QB_DUPLICATE_DOC_NUMBER , NotificationActions.QB_DUPLICATE_NAME, etc. Is this intentional?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@priosshrsth we had not implemented NotificationActions.QB_DUPLICATE_DOC_NUMBER or any other actions before. This is recent change.

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) <noreply@anthropic.com>
SandipBajracharya and others added 2 commits April 29, 2026 14:57
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants