> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# TRANSFER IN TRANSIT DEDUP

# 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`](../lib/transfer-in-transit.ts).

Worked example (CFO-9325, 2026-10-06): a $20,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.72 and `available`
$258,603.72 at snapshot time. The summary jumped +$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).

| Consumer | Where | Notes |
| - | - | - |
| Dashboard (live net worth, cash, account rows) | `hooks/queries/use-accounts.ts` → `applyTransfersInTransit` | lowers `balance_current` / `balance_current_usd` on the fetched copy; adds `transfer_in_transit_usd` and `pre_transit_balance_current` |
| AI metrics (chat, voice, PDF/PFS) | `lib/financial-data-cache.ts` → `getFinancialMetrics` | `cashOnHand` and the tax buckets |
| AI account listing | `lib/financial-data-cache.ts` → `getAccountDetails` | same fields as the dashboard |
| Daily snapshot cron | `app/api/cron/process-daily-snapshot/route.ts` | matched once per user across items; the gap guard runs at all four cash sites (fresh, fallback, cached, visibility) against the balances each one sums |

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.