Skip to Content
Source PipelineOperational Gotchas

Operational Gotchas

Plaid’s data model has several behaviors that produced real production incidents. Each now has a defensive mechanism in the sync path (apps/api/src/handlers/plaid-sync.ts) or the feed layer (packages/persistence/src/bank-transaction-feed-repository.ts) — this page documents each failure mode, why it happens, the fix, and what not to break, because every one of these defenses looks like dead code until you know the incident behind it.

Pending → posted re-mints the transaction_id

Failure mode (prod, 2026-06-23): duplicate wires in the feed — one sparse pending row (“Wire Transfer”) plus one fully-described posted row for the same movement. Plaid mints a new transaction_id when a pending transaction posts, linking back via pending_transaction_id. Some institutions (notably wires/ACH) deliver the posted transaction as added but never send the pending one in removed, so both stay live.

Fix: the sync run reconciles them itself. For every synced transaction referencing a still-live pending version, it synthesizes the removal Plaid omitted, then retires the pending OSE by pointing it at the posted one — it posted, it didn’t vanish:

// apps/api/src/handlers/plaid-sync.ts — pending → posted reconciliation await appendTxn(deps, { providerTransactionId: pendingTransactionId, state: 'removed', ... }); postingReconciliations.push({ pendingOseId: `ose:bank:${pending.id}`, postedOseId: `ose:bank:${postedVersion.id}`, }); // later, after promoteVersions so the posted OSE exists: await deps.promotion.operatingEvents.supersede({ id: pendingOseId, supersededByOseId: postedOseId, reason: 'bank_transaction_posted', });

Idempotent: a re-sync sees the pending version already removed/superseded and skips. Fifteen live prod pairs were repaired by a one-time backfill script when this shipped.

Never dedupe pending/posted pairs by amount + date heuristics — pending_transaction_id is the only reliable link, and the reconciliation must run after promotion so the posted OSE exists to point at.

Running the Plaid Link flow again over an already-connected bank creates a new Item (new connection, new transaction ids) covering the same accounts — the feed shows every transaction twice. The correct flow for repairing a connection is Plaid update mode, which reuses the existing Item. If a duplicate Item already exists, the remediation is item/remove on the old Item plus deleting the old connection.

Update-mode deselect → reselect re-mints the account_id

Even within update mode, deselecting then reselecting an account makes Plaid mint a new account_id (and new transaction ids). Two defenses:

  1. Feed scoping. The Power Query feed only returns transactions on active accounts; the deselected old account is flipped inactive (deactivateAccountsNotIn) and its history filtered out — without this the feed returns both copies (see the header comment in bank-transaction-feed-repository.ts, which calls this filter load-bearing).
  2. Re-pull reconciliation. The re-pulled transactions arrive as added under new ids. The sync builds an orphan pool of current versions whose payload account_id is no longer a known account on the connection, matches each new added by content fingerprint (transactionContentFingerprint, multiset-by-count so N identical transactions match N-to-N), and re-keys the existing version in place via rekeyVersion. The version id is preserved, so its ose:bank:${id} → ledger chain is untouched and nothing is re-promoted or duplicated.

Idempotency map (what protects what)

HazardGuard
Duplicate/retried webhookcursor-driven /transactions/sync — replays find nothing new
Racing syncs on one cursorappendTxn skips redundant added; UNIQUE(tenant_id, provider_transaction_id, version) backstop
Replayed promotiondeterministic OSE id ose:bank:${version.id}already_saved
Mid-run crashfailSyncRun + rethrow → SQS retry from the un-advanced cursor
Pending→posted re-syncpending version already removed/superseded → skip

Acks that are deliberate no-ops

The worker acks rather than retries in cases a retry cannot fix: unknown item_id (the Link flow hasn’t populated provider_item_id yet), a vanished connection, or a connection with no stored access token. Retrying these would just poison the queue. Conversely, anything transient rethrows so SQS redelivers.

When debugging a “missing transactions” report, check in order: (1) is the connection active with an access token? (2) did the sync run complete (cursor advanced) or fail? (3) is the account attributed to a vehicle — unattributed accounts store raw versions but promote nothing (see Plaid sync); (4) is the account still active, or was it re-keyed by an update-mode reselect?

Cross-links: promotion mechanics in Plaid sync; what happens after acceptance in /fund-accounting.

Last updated on