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 ofbalance.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,000 of funds that morning.
Wells Fargo still reported savings current 258,603.72 at snapshot time. The summary jumped +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)
- The bank account has a pending outflow of X (
transactions.pending = true, positive amount in Plaid’s convention). - The same X has already arrived in another of the user’s counted accounts:
an
investment_transactionsrow with a negative amount and type/subtypetransfer/transfer,cash/depositorcash/contribution, not canceled, dated within 3 days of the pending outflow. Matching is one-to-one, closest date first. - Lossless proof: the bank still reports
current − available ≥ X.
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 − availablegap, 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,
currentdrops, the gap closes and nothing more is subtracted. - A bank that already removes pending debits from
currentshows no gap, so the rule never fires there. - An unknown
availablebalance means no correction (never treated as 0).
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_transactionsonly. - 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 Clerkuser_…id. Client gateisTransferInTransitDedupEnabledClient(), server gateisTransferInTransitDedupEnabledForServer()(2s timeout). Any flag error resolves tofalse, 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_accountitself: for a bank account, rawbalance_currentminus the correctedcash_on_hand.
Observability
[TRANSFER_IN_TRANSIT]log line in the daily-snapshot cron (Vercel logs), with per-account amounts.- PostHog event
transfer_in_transit_dedupedwithsuppressed_countandsuppressed_usd_totalonly, once per user per cron run. Account ids are left out on purpose, to keep financial detail in analytics to a minimum.