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.
Re-link creates a duplicate Item → feed doubles
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:
- 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 inbank-transaction-feed-repository.ts, which calls this filter load-bearing). - Re-pull reconciliation. The re-pulled transactions arrive as
addedunder new ids. The sync builds an orphan pool of current versions whose payloadaccount_idis no longer a known account on the connection, matches each newaddedby content fingerprint (transactionContentFingerprint, multiset-by-count so N identical transactions match N-to-N), and re-keys the existing version in place viarekeyVersion. The version id is preserved, so itsose:bank:${id}→ ledger chain is untouched and nothing is re-promoted or duplicated.
Idempotency map (what protects what)
| Hazard | Guard |
|---|---|
| Duplicate/retried webhook | cursor-driven /transactions/sync — replays find nothing new |
| Racing syncs on one cursor | appendTxn skips redundant added; UNIQUE(tenant_id, provider_transaction_id, version) backstop |
| Replayed promotion | deterministic OSE id ose:bank:${version.id} → already_saved |
| Mid-run crash | failSyncRun + rethrow → SQS retry from the un-advanced cursor |
| Pending→posted re-sync | pending 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.