Skip to main content

Transfers in transit (bank → brokerage double count)

TL;DR: when a user moves money from their bank to their own brokerage, the brokerage often credits it the same day while the bank keeps the debit pending for one to three days. The bank leaves a pending debit out of balance.current, so for those days the same dollars sit in both accounts and net worth is overstated by the transfer amount. When the bank posts, the dashboard shows a phantom loss of the same size. We fix it by subtracting the transfer from the bank account at read time while it is in transit. Stored balances are never changed. The rule lives in lib/transfer-in-transit.ts. Worked example (CFO-9325, 2026-10-06): a 20,000WellsFargosavings→Schwabtransfer.Schwabrecordeditandtheuserbought20,000 Wells Fargo savings → Schwab transfer. Schwab recorded it and the user bought 20,000 of funds that morning. Wells Fargo still reported savings current 278,603.72and‘available‘278,603.72 and `available` 258,603.72 at snapshot time. The summary jumped +19,838,thenthedashboardshowed−19,838, then the dashboard showed −17,901 the next day when Wells Fargo posted. The same user hit it with $15,000 on Sep 26–29.

The rule (all required)

  1. The bank account has a pending outflow of X (transactions.pending = true, positive amount in Plaid’s convention).
  2. The same X has already arrived in another of the user’s counted accounts: an investment_transactions row with a negative amount and type/subtype transfer/transfer, cash/deposit or cash/contribution, not canceled, dated within 3 days of the pending outflow. Matching is one-to-one, closest date first.
  3. Lossless proof: the bank still reports current − available ≥ X.
The bank account is then counted at current − X.

Why it is safe

  • The subtracted amount is always a real pending payment at that bank and never more than the bank’s own current − available gap, so the corrected balance can never go below the bank’s own available balance.
  • The worst case of a wrong match is counting a real pending payment a day or two early.
  • It turns itself off: once the bank posts, current drops, the gap closes and nothing more is subtracted.
  • A bank that already removes pending debits from current shows no gap, so the rule never fires there.
  • An unknown available balance means no correction (never treated as 0).
Do not “fix” this by switching to balance.available: card holds and other pending items would then show up as daily losses.

Where it is applied

Every consumer that sums bank cash applies the same correction, so the live value and the stored snapshot agree. Fixing only the snapshot would turn the phantom drop into a phantom gain (live still inflated, baseline corrected). In snapshot rows, the derived columns (cash_on_hand, total_assets, net_worth, and every daily_snapshots_summary total) are corrected. The passthrough balance_current / balance_available columns stay raw, the same split the cash-sweep dedup uses.

Not covered

  • Days when the bank shows nothing at all. On Sep 26–27 Schwab had the money on a Saturday and Wells Fargo showed no pending item until Monday, so there was nothing to match.
  • Bank → bank transfers. The inbound side is read from investment_transactions only.
  • SnapTrade brokerages, manual accounts, non-USD accounts.
  • The SQL layer (account_effective_balances, account_details, the net worth RPCs). SQL cannot read the PostHog flag. A follow-up migration can add the correction unconditionally, as the cash-sweep view did (PR #2563).
  • Past history. Rows written before the flag was on keep the double count.

Rollout and rollback

  • Flag: PostHog transfer-in-transit-dedup, targeted by Clerk user_… id. Client gate isTransferInTransitDedupEnabledClient(), server gate isTransferInTransitDedupEnabledForServer() (2s timeout). Any flag error resolves to false, which is the exact pre-fix behavior.
  • Kill switch: flag off and live reads revert instantly. Snapshot rows written while it was on keep the corrected values. Those values are the right ones, so no repair is needed unless a match turns out to be wrong. The exact amount per account per day is recoverable from snapshots_by_account itself: for a bank account, raw balance_current minus the corrected cash_on_hand.

Observability

  • [TRANSFER_IN_TRANSIT] log line in the daily-snapshot cron (Vercel logs), with per-account amounts.
  • PostHog event transfer_in_transit_deduped with suppressed_count and suppressed_usd_total only, once per user per cron run. Account ids are left out on purpose, to keep financial detail in analytics to a minimum.