transaction documentation

This commit is contained in:
Emil Lerch 2026-05-07 14:50:08 -07:00
parent 88de7a9882
commit a26470f46a
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 98 additions and 47 deletions

View file

@ -58,6 +58,19 @@ Ask the user instead.**
stop. You do not commit. You do not prepare a commit. You hand off the
working tree and wait.
### Documentation-file conventions
- **`TODO.md` holds only what's still open.** Git already tracks what was
done and when. Do NOT add "DONE" markers, completion status, strikethrough,
or "shipped in …" blurbs to TODO entries — just delete the section when
the work is finished. If follow-up work came out of the finished task,
add it as a new top-level section; don't leave the parent entry around
as a historical wrapper.
- **`REPORT.md` is untracked on purpose.** It's a personal workflow doc
living in the repo root only until it moves to `~/finance`. Edit it
freely when asked; don't treat it as part of the repo surface. Don't
mention it in commit messages for unrelated work.
---
## Commands
@ -281,6 +294,8 @@ command.
- **Portfolio auto-detection.** Both CLI and TUI auto-load `portfolio.srf` from cwd if no explicit path is given. If not found in cwd, falls back to `$ZFIN_HOME/portfolio.srf`. `watchlist.srf` and `.env` follow the same cascade. `metadata.srf` and `accounts.srf` are loaded from the same directory as the resolved portfolio file.
- **`transaction_log.srf` is a sibling file.** Optional. Lives next to `portfolio.srf` / `accounts.srf`. Holds user-declared `transfer::` records so the contributions pipeline can tell internal account-to-account movement apart from real external contributions. Only `type::cash` is wired in v1 — `type::in_kind` parses but is rejected downstream. Missing file → matcher is a no-op. See `REPORT.md` §5 "Transfer log" for the user-facing guide.
- **Server sync is optional.** The `ZFIN_SERVER` env var enables parallel cache syncing from a remote zfin-server instance. All server sync code silently no-ops when the URL is null.
## Dependencies

79
TODO.md
View file

@ -264,60 +264,45 @@ flag in `accounts.srf`. When set on an account:
See `src/analytics/analysis.zig` `AccountTaxEntry.direct_indexing`
and `src/commands/audit.zig` `displaySchwabSummaryRatioSuggestions`.
## Transaction log file (transaction_log.srf) — priority HIGH
## In-kind transfer support (`type::in_kind`) — priority MEDIUM
`portfolio.srf` answers "what do I have" — it's a snapshot of
state. Some events that affect contribution attribution aren't
state; they're things that happened. The biggest current gap:
account transfers get double-counted as contributions because the
receiving side's `new_*` lots count toward attribution and the
sending side's `lot_removed` is silently ignored.
`transaction_log.srf` parses `type::in_kind` records but the
contributions matcher always rejects them with "in-kind transfers
not yet supported in v1." In-kind movements need per-symbol
matching across accounts: an in-kind transfer of 100 VTI shares
from Acct A to Acct B shows up as `lot_removed` on A + `new_stock`
on B (or a `rollup_delta` share increase if B already had a VTI
lot), neither of which can be matched by the current
amount-based cash matcher.
Proposed: new `transaction_log.srf` file alongside `accounts.srf`,
scoped minimally for now:
Proposed: a second pass in `matchTransfers` that iterates
`type::in_kind` records and looks for same-symbol matches across
`lot_removed` on `from` + `new_stock`/`rollup_delta` on `to`
within the window. Gated on share-count and open_price sanity so
a partial transfer doesn't false-positive against an unrelated
edit.
```
transfer::2026-05-02,amount:num:100000,from::Schwab Brokerage,to::Tax Loss[,note::...]
```
Driver: when the user starts moving positions between accounts
directly (e.g. Roth conversion of already-held shares, 401k →
rollover IRA in-kind) rather than liquidating and re-buying.
Contribution pipeline reads entries in the current window and nets
them out of both endpoints' classifications so the grand total and
`compare`'s attribution both reflect only external money flow.
Audit can also surface a warning for any large `new_*` lot that
doesn't have a matching transfer record, nudging the user to
confirm real money vs. add a transfer entry.
## Audit large-lot threshold tuning — priority LOW
### Scope for first pass
`src/commands/audit.zig` uses `audit_large_lot_threshold: f64 =
10_000.0` as the cutoff for "surface this new lot for confirmation."
The value is a judgment call; revisit if:
- Only `transfer::` records (no buys/sells, no dividend logs —
those stay inferred from the portfolio diff).
- File resolution: ZFIN_HOME cascade, same as `portfolio.srf` /
`accounts.srf` / `watchlist.srf`.
- Parser + record type in `src/models/`.
- Wire into `src/commands/contributions.zig` `computeReport`:
after classification, subtract matched transfer pairs from
contribution totals (emit a new `transfer_in` / `transfer_out`
kind or similar).
- Audit surfacing of unmatched large `new_*` lots (optional; can
be a follow-up).
- Doc update: `REPORT.md` workflow and `accounts.srf` sibling
file; contributions classification matrix gets a new row.
- $10k proves too aggressive (weekly payroll ESPP accruals near
the limit spam the report),
- $10k proves too permissive (large DRIP confirmations or CD
maturities slip past uncalled),
- the user wants per-account thresholds (e.g. $25k for
high-turnover brokerage, $5k for IRAs).
### Out of scope for first pass
- Buy/sell transaction records (still inferred from diff).
- Dividend/DRIP logs (still inferred from diff).
- Historical reconstruction (transaction log is forward-only —
existing reviews stay as-is).
### Driver
User has transfers expected next month. Without this fix, the
next review cycle's contribution total will be inflated by
whatever gets transferred. Related: the `direct_indexing` flag
(done) handles tracking drift on the proxy lot itself; the
transfer log handles the portfolio-total double-count. Different
problems, different fixes.
If runtime tuning becomes necessary, either a `--large-lot
<amount>` flag on `zfin audit` or a global `audit_large_lot_threshold`
field on `accounts.srf`-the-file would be reasonable extensions.
Until then the constant is the single knob.
## Torn SRF files from server sync (recurring bug)

View file

@ -101,6 +101,57 @@
//! lots even though their ratio is 1.0, bridging the brokerage-vs-
//! portfolio value gap that accumulates from tracking error.
//!
//! ### Transfer reclassification (`transaction_log.srf`)
//!
//! Records in `transaction_log.srf` flag internal money movement
//! between accounts the user owns. When present, the matcher runs
//! after Pass 1/Pass 2 and flips destination/source Changes to
//! dedicated transfer kinds that contribute $0 to attribution
//! fixing the double-count where a transfer's destination lot would
//! otherwise read as a fresh external contribution.
//!
//! Four reclassification kinds emerge from the matcher:
//!
//! - `transfer_in` destination lot (or cash-dest pool)
//! fully attributed to a transfer.
//! Replaces the base `new_*` kind.
//! - `partial_transfer_in` destination lot partially attributed.
//! Residual (`value() transfer_attributed`)
//! still counts toward attribution as
//! "pre-existing cash that funded the
//! rest of the lot."
//! - `transfer_out` sending-side match (negative
//! `cash_delta` or `lot_removed`). Best-
//! effort: a missing `from` side is
//! silent, not an error (the sending
//! account may not be in portfolio.srf).
//! - `unmatched_transfer` record in the window that couldn't
//! be matched. Surfaces in the Flagged
//! section with a reason string; the
//! transfer amount stays out of
//! attribution either way.
//!
//! | Scenario | Kind | Section | In Grand Total | In Attribution |
//! |-----------------------------------------------|------------------------|------------------|:--------------:|:--------------:|
//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no |
//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual |
//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no |
//! | Record with no match (bad dest, in_kind, ) | `unmatched_transfer` | Flagged | no | no |
//!
//! Cash-destination records don't flip the original `cash_delta` /
//! `new_cash` Change (a single cash delta can be drained by
//! multiple records, which `kind` can't represent). Instead the
//! matcher appends a synthetic `transfer_in` Change for the
//! Transfers section and records the attributed amount in
//! `Report.cash_attributed_by_account`, which the per-account totals
//! and attribution summary subtract from cash-side contributions.
//!
//! The matcher also composes with `direct_indexing::true`: a transfer
//! into a direct-indexing account matches normally on the destination
//! lot; subsequent tracking-error drift on that lot is still swallowed
//! by the direct-indexing tolerance. Different problems, different
//! passes.
//!
//! ## Other architecture notes
//!
//! Relies on: portfolio.srf being tracked in a git repo, and the `git`