//! `zfin contributions` — show money added to the portfolio since the //! last recorded state in git. //! //! Four modes: //! - No flags (default): //! - dirty working tree: HEAD vs working copy //! - clean working tree: HEAD~1 vs HEAD (review last commit) //! - `--since `: commit-at-or-before(DATE) vs HEAD (or working copy if dirty) //! - `--since --until `: commit-at-or-before(D1) vs commit-at-or-before(D2) //! - `--until ` alone: rejected; window is ambiguous //! //! The `--since` / `--until` flags use `commitAtOrBeforeDate` in //! `src/git.zig`, which runs `git log --until= -1 -- portfolio.srf` //! to pick the most recent commit at or before the requested date. //! Relative forms (1M, 3Q, 1Y) are also accepted — parsed by //! `cli.parseAsOfDate` and resolved to an absolute date before being //! passed in. //! //! ## Classification matrix //! //! Every lot-level change gets exactly one `ChangeKind` assigned at //! diff time in `computeReport`. Downstream consumers (section printer, //! per-account summary, `computeAttributionSpec` used by `compare`) all //! read the pre-classified kinds — there is no post-hoc reclassification //! in any consumer. Single point of truth so the grand total in //! `zfin contributions` and the attribution line in `zfin compare` //! always agree over the same window. //! //! ### Base classifications (same for every account) //! //! - `new_stock` — stock lot appeared (drip::false) //! - `new_drip_lot` — stock lot appeared (drip::true → confirmed DRIP) //! - `new_cash` — cash lot appeared (fresh line, not a balance bump) //! - `new_cd` — CD opened //! - `new_option` — option opened //! - `drip_confirmed` — same-key drip::true stock lot, Δshares > 0 //! - `rollup_delta` — same-key drip::false stock lot, Δshares > 0 //! (ambiguous: DRIP or contribution) //! - `drip_negative` — same-key stock lot, Δshares < 0 (unusual) //! - `cash_delta` — same-key cash lot, Δshares, default noise //! - `cd_matured` — CD disappeared, maturity_date ≤ today //! - `cd_removed_early` — CD disappeared, maturity_date > today //! - `lot_removed` — stock/cash/option lot disappeared //! - `lot_edited` — secondary-key match across broken strict keys //! (open_date/open_price/symbol-alias rewrite) //! - `price_only` — same-key, only the `price::` field changed //! - `flagged` — any other edit shape (maturity_date change, etc.) //! //! ### Cash-account opt-in (`cash_is_contribution::true` in accounts.srf) //! //! Most cash-account activity is internal flow — DRIP cash legs, //! dividend credits, CD coupons, settlement sweeps — which is why //! `cash_delta` is noise by default. But for payroll-adjacent //! accounts (ESPP accrual, direct 401k cash deposits, HSA employer //! contributions), a positive cash movement IS the contribution. //! The `cash_is_contribution::true` flag in `accounts.srf` opts an //! account into "positive cash_delta = real contribution" semantics. //! //! Opted-IN accounts: //! //! | Scenario | Kind | Section | In Grand Total | In Attribution | //! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:| //! | Brand-new cash lot appears | `new_cash` | New contributions | yes | yes | //! | Existing cash, balance up | `cash_contribution`| New contributions | yes | yes | //! | Existing cash, balance down | `cash_delta` | Cash deltas (raw) | no | no | //! | Cash lot fully removed | `lot_removed` | Flagged for review | no | no | //! //! Opted-OUT accounts (default): //! //! | Scenario | Kind | Section | In Grand Total | In Attribution | //! |-------------------------------|--------------------|----------------------|:--------------:|:--------------:| //! | Brand-new cash lot appears | `new_cash` | New contributions | yes | yes | //! | Existing cash, balance moves | `cash_delta` | Cash deltas (raw) | no | no | //! | Cash lot fully removed | `lot_removed` | Flagged for review | no | no | //! //! The asymmetry on negative Δ for opted-in accounts is intentional: //! a withdrawal from ESPP/HSA is rare and semantically different //! from "a contribution of negative money." The branch for that //! (withdraw-as-negative-contribution) is one `if (delta < 0)` in //! `computeReport` if/when the need arises. //! //! ### Direct-indexing accounts (`direct_indexing::true` in accounts.srf) //! //! Direct-indexing proxies hold a basket of underlying stocks //! tracked as a single benchmark via `ticker::`. The basket //! naturally drifts against the benchmark week-to-week (tracking //! error) and the user rebalances periodically — producing small //! share-count adjustments that aren't real money flow. //! //! When a lot in a flagged account goes through `detectEdits` //! (strict-key broken but secondary key matches), the residual //! share-delta tolerance is loosened from 0.01% to 1%. The identity //! match still collapses to `lot_edited`; residuals under 1% are //! suppressed entirely instead of surfacing as `rollup_delta` / //! `drip_negative`. Real contributions to direct-indexing accounts //! (e.g. a $100k buy-in on a multi-million basket = ~1.2%) still //! surface because they're above the tolerance. //! //! The `zfin audit` command uses the same flag for its companion //! behavior: emit a `price_ratio` adjustment suggestion for these //! 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 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. //! //! ### Which records does the matcher consider? //! //! The matcher runs against the records that are NEW in the after-side //! `transaction_log.srf` relative to the before-side. Concretely: //! `prepareReport` loads the file at both `before_rev` and the //! after-side (working copy or `after_rev`), parses each, and passes //! the set difference (`after - before`, by `TransferRecord.eql`) to //! the matcher. See `diffTransferLogs`. //! //! Why not filter by `transfer::DATE` against the diff's git //! timestamp window? Because the user's natural workflow is to //! record a transfer days, weeks, or months after the actual //! transaction date. A back-dated record is the rule, not the //! exception. The original date-window filter rejected those //! back-dated records and produced "unmatched contribution" noise //! the user couldn't quiet without changing the record's date //! to fall inside the diff window — a workaround that destroyed //! the historical accuracy of `transaction_log.srf`. //! //! Editing a previously-recorded transfer (e.g. fixing a typo in //! `from::`) produces an old-form record in `before` and a new-form //! record in `after`. The old form silently drops out (its diff cycle //! is over); the new form is treated as a fresh record and re-pairs //! against the current diff. If no matching destination Change exists //! in the current diff (because the lot was added in an earlier //! commit), the record surfaces as `unmatched_transfer` — accept the //! noise or undo the edit. //! //! 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` //! executable existing on PATH. We never rely on comments; maturity is //! decided from the Lot.maturity_date field, not from the file's form. const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const git = @import("../git.zig"); const framework = @import("framework.zig"); const TimeRange = @import("TimeRange.zig"); const analysis = @import("../analytics/analysis.zig"); const transaction_log = @import("../models/transaction_log.zig"); const Money = @import("../Money.zig"); const Date = zfin.Date; const Lot = zfin.Lot; const LotType = @import("../models/portfolio.zig").LotType; // ── Public entry point ─────────────────────────────────────── /// Resolved endpoints for the contributions diff: the before/after /// commit range (from `git.resolveCommitRange`) plus the /// human-readable label for the report header. const Endpoints = struct { range: git.CommitRange, label: []const u8, }; pub const ParsedArgs = struct { before: ?git.CommitSpec = null, after: ?git.CommitSpec = null, }; pub const meta: framework.Meta = .{ .name = "contributions", .group = .timeseries, .synopsis = "Show money added since the last recorded state in git", .help = \\Usage: zfin contributions [opts] \\ \\Show contributions, withdrawals, and lot-level changes between \\two points in the portfolio's git history. Four modes: \\ \\ No flags (default): \\ dirty working tree: HEAD vs working copy \\ clean working tree: HEAD~1 vs HEAD (review last commit) \\ --since commit-at-or-before(DATE) vs HEAD (or \\ working copy when dirty) \\ --since --until commit-at-or-before(D1) vs \\ commit-at-or-before(D2) \\ --until alone rejected; window is ambiguous \\ \\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y). \\ \\Options: \\ --since Earliest side (resolves to commit-at- \\ or-before). \\ --until Latest side. Pair with --since. \\ --commit-before Pin the before commit directly. Same \\ grammar as --commit-after, minus \\ `working`. Useful when you committed \\ after your review date. \\ --commit-after Pin the after commit. SPEC accepts \\ YYYY-MM-DD, relative (1W/1M/1Q/1Y), \\ HEAD, HEAD~N, hex SHA, or `working` \\ for the working copy. \\ \\--since and --commit-before describe the same axis; pass at most \\one. Same for --until and --commit-after. \\ , .uppercase_first_arg = false, .user_errors = error{ DuplicateEndpoint, InvalidArg, MissingOpenDate, PrepareFailed, ResolveFailed, UnexpectedArg }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { const io = ctx.io; const today = ctx.today; const allocator = ctx.allocator; const tr_result = TimeRange.parse(io, allocator, today, cmd_args, .{ .accept_since = true, .accept_until = true, .accept_commit_before = true, .accept_commit_after = true, }) catch |err| switch (err) { error.MissingValue, error.InvalidValue, error.WorkingCopyOnBeforeSide, error.LiveNotAllowed, => return error.InvalidArg, error.DuplicateEndpoint, error.RepeatedFlag => return error.DuplicateEndpoint, error.OutOfMemory => return error.OutOfMemory, }; defer allocator.free(tr_result.consumed); // Reject any tokens TimeRange didn't consume — contributions has // no other flags or positionals. var consumed_set = std.AutoHashMap(usize, void).init(allocator); defer consumed_set.deinit(); for (tr_result.consumed) |idx| try consumed_set.put(idx, {}); for (cmd_args, 0..) |a, i| { if (consumed_set.contains(i)) continue; cli.stderrPrint(io, "Error: unexpected argument to 'contributions': "); cli.stderrPrint(io, a); cli.stderrPrint(io, "\n"); return error.UnexpectedArg; } // Translate Endpoint to CommitSpec. `--since` produces a date // endpoint; `--commit-before` a commit_spec endpoint. Map both // to the same CommitSpec union the existing run() expects. var parsed: ParsedArgs = .{}; if (tr_result.range.before) |ep| switch (ep) { .date => |d| parsed.before = .{ .date_at_or_before = d }, .commit_spec => |s| parsed.before = s, .live => unreachable, }; if (tr_result.range.after) |ep| switch (ep) { .date => |d| parsed.after = .{ .date_at_or_before = d }, .commit_spec => |s| parsed.after = s, .live => unreachable, }; // Validate `--since on or before --until` ordering for the // date-only form, matching legacy behavior. We can only check // when BOTH sides are date_at_or_before (the commit-spec form // is opaque until git resolves it). if (parsed.before) |b| if (parsed.after) |a| { if (b == .date_at_or_before and a == .date_at_or_before) { if (b.date_at_or_before.days > a.date_at_or_before.days) { cli.stderrPrint(io, "Error: --since must be on or before --until.\n"); return error.InvalidArg; } } }; return parsed; } pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; const io = ctx.io; const allocator = ctx.allocator; const out = ctx.out; const color = ctx.color; const as_of = ctx.today; const before = parsed.before; const after = parsed.after; const pf = ctx.resolvePortfolioPath(); defer pf.deinit(allocator); const portfolio_path = pf.path; return runImpl(io, allocator, svc, portfolio_path, before, after, as_of, color, ctx.globals.refresh_policy, out); } fn runImpl( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, before: ?git.CommitSpec, after: ?git.CommitSpec, as_of: Date, color: bool, refresh: framework.RefreshPolicy, out: *std.Io.Writer, ) !void { // Arena for all transient allocations: git subprocess buffers, duped path // strings, the snapshot blobs, the symbol set, price map, and the Report // itself. Portfolio objects use the base allocator (they own their own // deinit). One defer cleans everything up at once. var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); // Enforce the "an `after` with no `before` is ambiguous" rule at // the entry point so `resolveEndpoints`/`git.resolveCommitRangeSpec` // can assume the invariant. The legacy no-flag path passes both // as null and falls through to HEAD~1..HEAD / HEAD..WC. if (before == null and after != null) { cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n"); return; } var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, before, after, as_of, color, refresh, .verbose) catch return; defer ctx.deinit(); try printReport(out, &ctx.report, ctx.endpoints.label, color); try out.flush(); } /// Shared pipeline context: everything `run` and `computeAttributionSpec` /// both need from the git-backed diff. /// /// Owned fields split across two allocators: /// - `before_pf` / `after_pf` use the base allocator (their own /// `deinit` frees internals). /// - `endpoints`, `report`, and the snapshot blobs live in the /// supplied arena; the arena's own `deinit` cleans them up. /// `deinit` releases only the base-allocator-owned pieces. const ReportContext = struct { endpoints: Endpoints, before_pf: zfin.Portfolio, after_pf: zfin.Portfolio, report: Report, fn deinit(self: *ReportContext) void { self.before_pf.deinit(); self.after_pf.deinit(); } }; const PrepareError = error{PrepareFailed}; /// Run the common pipeline — resolve endpoints, read both blobs, /// parse both portfolios, fetch prices, build the report. /// /// Shared between `run` (prints the report) and /// `computeAttributionImpl` (aggregates totals). Centralizes the git /// plumbing and the price-loading step; callers own their output /// decisions. /// /// Stderr output is gated by `verbosity`: `.verbose` is the `run` /// path (user sees why things failed); `.silent` is the attribution /// path (failure just means "no attribution line", don't nag). fn prepareReport( io: std.Io, allocator: std.mem.Allocator, arena: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, before_spec: ?git.CommitSpec, after_spec: ?git.CommitSpec, as_of: Date, color: bool, refresh: framework.RefreshPolicy, verbosity: Verbosity, ) PrepareError!ReportContext { const repo = git.findRepo(io, arena, portfolio_path) catch |err| { if (verbosity == .verbose) { switch (err) { error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n"), error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n"), else => cli.stderrPrint(io, "Error locating git repo.\n"), } } return error.PrepareFailed; }; const status = git.pathStatus(io, arena, repo.root, repo.rel_path) catch { if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n"); return error.PrepareFailed; }; if (status == .untracked) { if (verbosity == .verbose) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n"); return error.PrepareFailed; } const dirty = status == .modified; const endpoints = resolveEndpoints(io, arena, repo, before_spec, after_spec, dirty, verbosity) catch return error.PrepareFailed; // Pull both sides: before is always from git; after is either // from git (at some revision) or from the working copy. const before = git.show(io, arena, repo.root, endpoints.range.before_rev, repo.rel_path) catch |err| { if (verbosity == .verbose) { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ endpoints.range.before_rev, @errorName(err) }) catch "Error reading before-side portfolio.\n"; cli.stderrPrint(io, msg); } return error.PrepareFailed; }; const after = if (endpoints.range.after_rev) |rev| git.show(io, arena, repo.root, rev, repo.rel_path) catch |err| { if (verbosity == .verbose) { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ rev, @errorName(err) }) catch "Error reading after-side portfolio.\n"; cli.stderrPrint(io, msg); } return error.PrepareFailed; } else std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, arena, .limited(10 * 1024 * 1024)) catch { if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n"); return error.PrepareFailed; }; var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch { if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n"); return error.PrepareFailed; }; errdefer before_pf.deinit(); var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch { if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n"); return error.PrepareFailed; }; errdefer after_pf.deinit(); // Fetch current prices (cache-hit preferred) for DRIP/share-delta valuation. var prices = std.StringHashMap(f64).init(arena); var sym_set = std.StringHashMap(void).init(arena); for (before_pf.lots) |l| { if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed; } } for (after_pf.lots) |l| { if (l.security_type == .stock and !(l.price != null and l.ticker == null)) { sym_set.put(l.priceSymbol(), {}) catch return error.PrepareFailed; } } var syms: std.ArrayList([]const u8) = .empty; var sit = sym_set.keyIterator(); while (sit.next()) |k| syms.append(arena, k.*) catch return error.PrepareFailed; if (syms.items.len > 0) { var load_result = cli.loadPortfolioPrices(io, svc, syms.items, &.{}, refresh, color); defer load_result.deinit(); var pit = load_result.prices.iterator(); while (pit.next()) |entry| { prices.put(entry.key_ptr.*, entry.value_ptr.*) catch return error.PrepareFailed; } } // Load accounts.srf (if present) so opt-in cash-delta // reclassification fires at diff time. When missing or // unparseable, computeReport falls back to the default cash_delta // classification — no account gets the opt-in treatment. var account_map_opt = svc.loadAccountMap(portfolio_path); defer if (account_map_opt) |*am| am.deinit(); // Load transaction_log.srf from BOTH sides of the diff. The // matcher considers only records that are NEW in the after-side // log (i.e. added to `transaction_log.srf` since `before_rev`). // This decouples record matching from the record's // `transfer::DATE`, allowing back-dated entries to pair with // the diff that introduced them. // // Path is sibling to portfolio.srf in the same git repo. // Missing-on-either-side is OK: file may not have existed in // before_rev (treat as empty) or may not exist in working copy // (treat as no records → matcher is a no-op). const tlog_rel_path = blk: { const dir_end = if (std.mem.lastIndexOfScalar(u8, repo.rel_path, '/')) |idx| idx + 1 else 0; break :blk std.fmt.allocPrint(arena, "{s}transaction_log.srf", .{repo.rel_path[0..dir_end]}) catch return error.PrepareFailed; }; var before_tlog_opt: ?transaction_log.TransactionLog = blk: { const data = git.show(io, arena, repo.root, endpoints.range.before_rev, tlog_rel_path) catch break :blk null; break :blk transaction_log.parseTransactionLogFile(arena, data) catch null; }; defer if (before_tlog_opt) |*tl| tl.deinit(); var after_tlog_opt: ?transaction_log.TransactionLog = blk: { if (endpoints.range.after_rev) |rev| { const data = git.show(io, arena, repo.root, rev, tlog_rel_path) catch break :blk null; break :blk transaction_log.parseTransactionLogFile(arena, data) catch null; } else { break :blk svc.loadTransferLog(portfolio_path); } }; defer if (after_tlog_opt) |*tl| tl.deinit(); // Diff: keep only the records new in after. The slice borrows // from after_tlog_opt's record memory; both must outlive the // matcher call below (they do — same arena scope). const new_records: ?[]const transaction_log.TransferRecord = blk: { const after_tl = if (after_tlog_opt) |*tl| tl else break :blk null; const before_ptr: ?*const transaction_log.TransactionLog = if (before_tlog_opt) |*tl| tl else null; break :blk diffTransferLogs(arena, before_ptr, after_tl) catch return error.PrepareFailed; }; const report = computeReport( arena, before_pf.lots, after_pf.lots, &prices, as_of, .{ .account_map = if (account_map_opt) |*am| am else null, .transfer_log = new_records, }, ) catch { if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n"); return error.PrepareFailed; }; return .{ .endpoints = endpoints, .before_pf = before_pf, .after_pf = after_pf, .report = report, }; } /// Whether `resolveEndpoints` / `prepareReport` should print /// explanatory stderr messages when the window can't be resolved. The /// main `run` command uses `.verbose` so the user sees why the command /// failed; the internal `computeAttributionSpec` helper uses `.silent` /// because a missing git window is an expected null-return case, not /// a hard error. const Verbosity = enum { verbose, silent }; /// Resolve `since` / `until` flags plus dirty-working-tree state to /// the pair of git revisions to diff, along with a human-readable /// label for the report header. /// /// Pure SHA-level resolution is delegated to `git.resolveCommitRange`; /// this wrapper adds: /// - Label formatting (CLI-level presentation concern). /// - Stderr messages on failure (respecting `verbosity`). /// - A friendly "resolved to the same commit" warning when /// `--since` and `--until` collapse. fn resolveEndpoints( io: std.Io, arena: std.mem.Allocator, repo: git.RepoInfo, before: ?git.CommitSpec, after: ?git.CommitSpec, dirty: bool, verbosity: Verbosity, ) !Endpoints { const range = git.resolveCommitRangeSpec(io, arena, repo, before, after, dirty) catch |err| { if (verbosity == .verbose) { switch (err) { error.NoCommitAtOrBefore => { // Report which spec triggered it. When both are // date specs we can't easily tell from here; // covers both by naming the before-side. var before_buf: [10]u8 = undefined; const before_str = specDisplayString(before, &before_buf); var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, before_str }) catch "Error: no commit at or before requested date.\n"; cli.stderrPrint(io, msg); }, error.InvalidArg => { cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n"); }, else => { cli.stderrPrint(io, "Error resolving commit range: "); cli.stderrPrint(io, @errorName(err)); cli.stderrPrint(io, "\n"); }, } } return error.ResolveFailed; }; // Label the endpoints based on the resolution mode. Matches the // legacy phrasing where possible so existing test assertions still // pass. const label = try buildLabelFromSpecs(arena, range, before, after, dirty); // Same-commit warning for the two-date window case. Legit confusion // trigger — the user asked for a diff between two dates that both // snap to the same commit (e.g., no activity in the window). if (before != null and after != null and verbosity == .verbose) { if (range.after_rev) |after_rev| { if (std.mem.eql(u8, range.before_rev, after_rev)) { cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n"); } } } // Snap-note warning: when the user provided a date-form spec and // the resolved commit's committer-date is >1 day before the // requested date, emit a muted hint so the user can verify the // selected commit matches their intent. See // `docs/notes/commit-window-edge-case.md` (aka TODO.md) for the // motivating scenario. if (verbosity == .verbose) { try maybeSnapNote(io, arena, repo, before, range.before_rev, "before"); } return .{ .range = range, .label = label }; } /// Render a `CommitSpec` for user-facing error messages. Dates and /// working-copy sentinels get formatted; refs are passed through. /// When `spec` is null, returns "(unset)". fn specDisplayString(spec: ?git.CommitSpec, date_buf: *[10]u8) []const u8 { const s = spec orelse return "(unset)"; return switch (s) { .git_ref => |r| r, .date_at_or_before => |d| std.fmt.bufPrint(date_buf, "{f}", .{d}) catch "????-??-??", .working_copy => "working", }; } /// If the user's before spec was a date form and the resolved commit /// sits more than 1 day earlier than the requested date, print a /// muted hint. Catches the "I committed after my review date" case /// where `--since 1W` picks up a commit 7+ days before the cutoff. fn maybeSnapNote( io: std.Io, arena: std.mem.Allocator, repo: git.RepoInfo, spec: ?git.CommitSpec, resolved_ref: []const u8, label: []const u8, ) !void { const s = spec orelse return; const requested_date = switch (s) { .date_at_or_before => |d| d, else => return, }; // Get the committer-date of the resolved commit. `%ct` gives a // Unix timestamp. const ts = git.commitTimestamp(io, arena, repo.root, resolved_ref) catch return; const commit_date = zfin.Date.fromEpoch(ts); if (!commit_date.lessThan(requested_date)) return; const gap_days = requested_date.days - commit_date.days; if (gap_days <= 1) return; var msg_buf: [320]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, "(git {s} uses commit {s} from {f}, {d} day{s} before requested {f} — " ++ "use --commit-{s} HEAD or a later date to pin to your latest reconciliation commit)\n", .{ label, shortSha(resolved_ref), commit_date, gap_days, if (gap_days == 1) "" else "s", requested_date, label, }, ) catch return; cli.stderrPrint(io, msg); } /// Abbreviate a commit ref for display. SHAs get shortened to 7 /// chars; symbolic refs (HEAD, HEAD~1) stay as-is. fn shortSha(ref: []const u8) []const u8 { if (std.mem.startsWith(u8, ref, "HEAD")) return ref; if (ref.len > 7) return ref[0..7]; return ref; } /// Build a report-header label from the resolved range + the /// user-provided specs. Date specs render their requested form; /// refs render verbatim; null falls back to the legacy HEAD~/HEAD /// naming. fn buildLabelFromSpecs( arena: std.mem.Allocator, range: git.CommitRange, before: ?git.CommitSpec, after: ?git.CommitSpec, dirty: bool, ) ![]const u8 { // Preserve the original date-based label when both specs are // date-form (most common user-facing flow). Fall back to a // spec-agnostic rendering otherwise. const before_date: ?Date = if (before) |b| switch (b) { .date_at_or_before => |d| d, else => null, } else null; const after_date: ?Date = if (after) |a| switch (a) { .date_at_or_before => |d| d, else => null, } else null; // If either spec is a non-date form, emit a label describing // the resolved range. Otherwise use the legacy date/label path // for back-compat with snapshot tests. const has_non_date = (before != null and before_date == null) or (after != null and after_date == null); if (has_non_date) { const before_str = try specLabel(arena, before, range.before_rev); const after_str = try specLabelAfter(arena, after, range.after_rev); return std.fmt.allocPrint(arena, "{s} vs {s}", .{ before_str, after_str }); } return buildLabel(arena, range, before_date, after_date, dirty); } fn specLabel(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: []const u8) ![]const u8 { const s = spec orelse return arena.dupe(u8, resolved_ref); return switch (s) { .git_ref => |r| arena.dupe(u8, r), .date_at_or_before => |d| std.fmt.allocPrint(arena, "commit at-or-before {f}", .{d}), .working_copy => arena.dupe(u8, "working copy"), }; } fn specLabelAfter(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: ?[]const u8) ![]const u8 { if (spec) |s| return specLabel(arena, s, resolved_ref orelse "working"); if (resolved_ref) |r| return arena.dupe(u8, r); return arena.dupe(u8, "working copy"); } /// Build the human-readable header label for a resolved range. fn buildLabel( arena: std.mem.Allocator, range: git.CommitRange, since: ?Date, until: ?Date, dirty: bool, ) ![]const u8 { // No date window → legacy labels, matches pre-since/--until wording. if (since == null) { return if (dirty) "Comparing working copy against HEAD" else "Working tree clean — comparing HEAD~1 against HEAD"; } var since_buf: [10]u8 = undefined; const since_str = std.fmt.bufPrint(&since_buf, "{f}", .{since.?}) catch "????-??-??"; if (until) |until_date| { var until_buf: [10]u8 = undefined; const until_str = std.fmt.bufPrint(&until_buf, "{f}", .{until_date}) catch "????-??-??"; return std.fmt.allocPrint(arena, "Comparing {s} ({s}) against {s} ({s})", .{ short(range.before_rev), since_str, short(range.after_rev.?), until_str, }); } // --since only: after side is HEAD or working copy. return if (dirty) std.fmt.allocPrint(arena, "Comparing {s} ({s}) against working copy", .{ short(range.before_rev), since_str }) else std.fmt.allocPrint(arena, "Comparing {s} ({s}) against HEAD", .{ short(range.before_rev), since_str }); } /// 7-char short SHA for display. Runtime behavior is already /// length-agnostic (accepts any `>= 7`), so this works for both SHA-1 /// (40-char) and SHA-256 (64-char) repos without modification. /// Slices rather than reallocs. fn short(sha: []const u8) []const u8 { return if (sha.len >= 7) sha[0..7] else sha; } // ── Attribution helper for compare ────────────────────────── /// Aggregated "money in" totals over a date window, produced by the /// contributions pipeline but distilled to the numbers needed for /// the compare-command attribution line. /// /// "Contributions" in the plain-English sense (what the user wrote a /// check for or what got DRIP'd back in) = `new_contributions` + /// `drip`. CD face-value rollovers are *not* here — moving a maturing /// CD's face value back into cash isn't new money, and `new_cash` /// records during that transaction don't double-count because the /// pipeline separates cd_matured from cash_delta. pub const AttributionSummary = struct { /// Fresh lots that represent outside money: 401k contributions, /// DRIP-false stock purchases, CD openings, option opens, cash /// top-ups. Matches the "New contributions / purchases" section /// in the full report. new_contributions: f64, /// Dividend reinvestments: new `drip::true` lots + share increases /// on same-key drip lots + rollup share deltas (ambiguous /// contribution-vs-DRIP cases, treated as DRIP here to avoid /// double-counting with cash contributions). drip: f64, pub fn total(self: AttributionSummary) f64 { return self.new_contributions + self.drip; } }; /// Run the contributions pipeline over a commit window and return the /// aggregated "money in" totals. Returns null on any failure — /// intended callers (e.g. `compare`) surface the attribution line /// opportunistically; a missing git repo or no resolvable commits /// shouldn't break the primary command. /// /// Parameters mirror `run` but without the writer/color: no output /// is produced. Failures swallow silently via the shared /// `prepareReport` helper's `.silent` verbosity. /// /// Classification logic is SOLELY in `computeReport` — this function /// just buckets pre-classified change kinds into their totals. In /// particular, opt-in cash-delta handling is resolved at diff time /// (cash_delta → cash_contribution on accounts marked /// `cash_is_contribution::true`), so the attribution line here and /// the grand total in the full `zfin contributions` report come out /// of the same classifier and always agree over the same window. pub fn computeAttributionSpec( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, before: ?git.CommitSpec, after: ?git.CommitSpec, as_of: Date, color: bool, refresh: framework.RefreshPolicy, ) ?AttributionSummary { if (before == null and after != null) return null; var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, before, after, as_of, color, refresh, .silent) catch return null; defer ctx.deinit(); return summarizeAttribution(ctx); } // ── Public audit hook ──────────────────────────────────────── /// Descriptor of a "large new lot" the audit command may want to /// surface. Emitted by `findUnmatchedLargeLots` for any new-side /// Change whose `value()` meets the caller's threshold and which /// was NOT reclassified by the transfer-log matcher. All string /// fields are caller-arena-owned through the `UnmatchedLargeLotSet` /// wrapper; the caller frees everything at once via `deinit`. pub const UnmatchedLargeLot = struct { /// The account the lot landed on (arena-owned copy). account: []const u8, /// Security ticker, or empty string for cash-kind lots. symbol: []const u8, /// `.stock`, `.cash`, `.cd`, etc. Drives the dest_lot shape in /// the suggested template. security_type: LotType, /// Dollar value of the lot (`Change.value()`). value: f64, /// Lot open_date. Used in the `dest_lot::SYMBOL@OPEN_DATE` /// template for stock / CD destinations. open_date: Date, }; /// Result wrapper that owns both the slice and the arena the string /// fields live in. Caller must `deinit`. pub const UnmatchedLargeLotSet = struct { lots: []UnmatchedLargeLot, arena: std.heap.ArenaAllocator, pub fn deinit(self: *UnmatchedLargeLotSet) void { self.arena.deinit(); } }; /// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd /// / cash_contribution) with `value() >= threshold` that weren't /// matched to a record in `transaction_log.srf` over the HEAD → /// working-copy window. Mirrors the `zfin contributions` zero-flag /// path — uses `prepareReport`'s shared git + portfolio + transfer /// plumbing so the classification is identical. Returns null if the /// pipeline can't resolve a window (not in a git repo, etc.). /// /// Consumed by `zfin audit` to prompt the user to either confirm /// the lot as an external contribution or add a transfer record /// when it was really an internal movement. Works whether or not /// `transaction_log.srf` exists — when absent, every large lot /// surfaces since nothing gets reclassified. pub fn findUnmatchedLargeLots( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, threshold: f64, as_of: Date, color: bool, refresh: framework.RefreshPolicy, ) ?UnmatchedLargeLotSet { var arena_state = std.heap.ArenaAllocator.init(allocator); errdefer arena_state.deinit(); const arena = arena_state.allocator(); // Explicit null/null selects the legacy zero-flag path: // before=HEAD~1..HEAD if clean, HEAD..working-copy if dirty. // Audit cares about the dirty-working-copy case in practice // (that's where "unconfirmed large lots" live), but both // branches are valid consumers. // // Separate allocator here so we can tear the whole thing down // via `arena_state.deinit` once we've copied out the descriptors. var ctx = prepareReport(io, allocator, arena, svc, portfolio_path, null, null, as_of, color, refresh, .silent) catch { arena_state.deinit(); return null; }; defer ctx.deinit(); const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, threshold) catch { arena_state.deinit(); return null; }; return .{ .lots = lots, .arena = arena_state }; } /// Pure filter: pick out new-side Changes with `value() >= threshold` /// and dupe their string fields into `arena`. Split out so tests can /// feed a synthetic `[]Change` without running the git/IO pipeline. /// /// Cash-destination transfers don't flip their original `new_cash` / /// `cash_contribution` Change (a single cash delta can be drained by /// multiple records, which the kind field can't represent). Instead /// the matcher records the attributed amount in /// `cash_attributed_by_account`. Subtract that here so a fully- /// attributed cash lot doesn't re-surface as "unmatched large lot." /// A partially-attributed cash lot surfaces only on the residual, /// matching the user's mental model: "this much of the lot is /// already documented, check the rest." /// /// Deliberately excludes `partial_transfer_in`. A partial lot already /// has an explicit transfer record acknowledging the large movement; /// the unmatched residual is typically small (pre-existing cash that /// topped the lot off) and surfacing it again would nag on something /// the user has already documented. If a residual is large enough to /// care about independently, the user can review the lot's full value /// via `zfin contributions` — this filter's job is to catch /// *unrecorded* large movements, not to re-flag partial ones. /// Pure filter: pick out new-side Changes whose unattributed value /// (`attributedValue()`) is at or above `threshold`, and dupe their /// string fields into `arena`. Split out so tests can feed a /// synthetic `[]Change` without running the git/IO pipeline. /// /// Cash-destination transfers don't flip their original `new_cash` / /// `cash_contribution` Change's kind (a single cash delta can be /// drained by multiple records, which the kind field can't /// represent). Instead the matcher accumulates the per-record /// attribution onto each consumed Change's `transfer_attributed` /// field, in iteration order. `attributedValue()` then returns the /// residual: 0 for fully-attributed lots, the unattributed /// remainder for partials. /// /// Deliberately excludes `partial_transfer_in`. A partial lot already /// has an explicit transfer record acknowledging the large movement; /// the unmatched residual is typically small (pre-existing cash that /// topped the lot off) and surfacing it again would nag on something /// the user has already documented. If a residual is large enough to /// care about independently, the user can review the lot's full value /// via `zfin contributions` — this filter's job is to catch /// *unrecorded* large movements, not to re-flag partial ones. fn collectUnmatchedLargeLots( arena: std.mem.Allocator, changes: []const Change, threshold: f64, ) ![]UnmatchedLargeLot { var out: std.ArrayList(UnmatchedLargeLot) = .empty; for (changes) |c| { const is_new_side = switch (c.kind) { .new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true, else => false, }; if (!is_new_side) continue; // Use attributedValue() so a fully-attributed cash lot // (whose `transfer_attributed` covers the whole `value()`) // drops out, and a partially-attributed cash lot surfaces // only on the unattributed remainder. const residual = c.attributedValue(); if (residual < threshold) continue; // open_date is populated in Pass 1's new-lot branch; absence // here would be a pipeline bug. const od = c.open_date orelse return error.MissingOpenDate; const account_copy = try arena.dupe(u8, c.account); const symbol_copy = try arena.dupe(u8, c.symbol); try out.append(arena, .{ .account = account_copy, .symbol = symbol_copy, .security_type = c.security_type, .value = residual, .open_date = od, }); } return out.toOwnedSlice(arena); } fn summarizeAttribution(ctx: ReportContext) AttributionSummary { // Aggregate. Classification logic matches the full-report sections: // - New contributions: new_stock + new_cash + new_cd + new_option // + cash_contribution (opt-in cash_delta) // + partial_transfer_in residual // (`value()` − `transfer_attributed`) // − cash-dest transfer totals // (from `cash_attributed_by_account`) // - DRIP: new_drip_lot + drip_confirmed + rollup_delta // `rollup_delta` is the ambiguous "share increased on a drip::false // lot" case. Lumping it with DRIP here matches the report's own // visual grouping (both shown as positive, both under DRIP-ish // headings) and prevents double-counting against cash_delta. // `cash_delta` (non-opt-in) is excluded — it's noisy cash movement // (DRIP legs, interest, CD coupons, settlement sweeps) that the // user hasn't explicitly flagged as a contribution source. // `lot_edited` is excluded — it's a reconciliation noise bucket. // `transfer_in` / `transfer_out` / `unmatched_transfer` → $0. // `partial_transfer_in` → residual only (attributedValue()). var new_contributions: f64 = 0; var drip: f64 = 0; for (ctx.report.changes) |c| switch (c.kind) { .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(), .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(), .partial_transfer_in => new_contributions += c.attributedValue(), else => {}, }; // Note: cash-dest transfer attribution is already removed by // `attributedValue()` on the per-Change side (matchCashDestination // accumulates into `transfer_attributed`). No second subtraction // off `cash_attributed_by_account` needed here. return .{ .new_contributions = new_contributions, .drip = drip }; } // ── Git discovery / invocation ─────────────────────────────── // // Git plumbing lives in `src/git.zig` (shared with future snapshot // features). This module only classifies which revisions to diff and // how to interpret the result. // ── Diff algorithm ─────────────────────────────────────────── /// Categorized change on a single lot-key. const ChangeKind = enum { new_stock, // lot appeared: stock purchase (drip::false) new_drip_lot, // lot appeared: stock with drip::true (confirmed DRIP reinvestment) new_cash, // lot appeared: cash added new_cd, // lot appeared: CD opened new_option, // lot appeared: option opened drip_confirmed, // same key on a drip::true stock lot, Δshares > 0 rollup_delta, // same key on a drip::false stock lot, Δshares > 0 (DRIP or contribution; can't distinguish) drip_negative, // same key, stock, Δshares < 0 (share sale on the same lot — unusual) cash_delta, // same key, cash, Δshares (treated as noise — interest, DRIP legs) /// Positive cash_delta on an account marked /// `cash_is_contribution::true` in accounts.srf. Reclassified at /// diff time (inside `computeReport`) so every downstream /// consumer — the full report, per-account summary, attribution /// totals in `compare` — sees the same classification. Without /// this single-point-of-truth, compare's attribution line and the /// contributions report's grand total could disagree on the same /// window. cash_contribution, cd_matured, // lot disappeared: CD with maturity_date <= today cd_removed_early, // lot disappeared: CD with maturity_date > today lot_removed, // lot disappeared: stock/cash/option /// Strict lot key broke (open_date / open_price / account rewritten) /// but the same (security_type, symbol, account) reappears on the /// other side with approximately the same share total. Classified /// as a lot edit — not counted as contribution, not counted as /// disposal. Fixes the phantom-contribution bug from reconciliation /// tweaks, CD auto-renewals rewriting `open_date`, and account /// renames that shift every lot in the account under a new name. lot_edited, price_only, // same key, price:: field changed, no share change flagged, // any other shape of edit // ── Transfer reclassifications (see `matchTransfers`) ──── /// Destination lot or cash_delta fully attributed to a transfer /// record in `transaction_log.srf`. Contributes $0 to attribution — /// it's internal money movement, not new contribution. Replaces /// the `new_stock` / `new_drip_lot` / `new_cash` / `new_cd` / /// `cash_contribution` classification the lot would otherwise /// receive. transfer_in, /// `lot_removed` or negative `cash_delta` on the sending account, /// credited against a transfer record. Contributes $0 to /// attribution. Also replaces the base classification. transfer_out, /// Destination lot or cash partially attributed to a transfer. /// The remaining value (`c.value() - c.transfer_attributed`) is /// still real new money and flows through attribution under the /// base classification's bucket (new_contributions or drip). partial_transfer_in, /// Transfer record in the window that couldn't be matched to any /// portfolio-diff Change. Emitted as a standalone Change (not /// attached to an existing lot) so it surfaces in the Flagged /// section. `detail` carries the human-readable reason. /// Contributes $0 to attribution. unmatched_transfer, }; const Change = struct { kind: ChangeKind, symbol: []const u8, account: []const u8, security_type: LotType, /// Δshares (after - before). Zero for price_only and lot_removed. delta_shares: f64 = 0, /// Price used to value delta_shares (open_price, current price, or manual price). unit_value: f64 = 0, /// For cd_matured / cd_removed_early / lot_removed: the before-lot's shares. face_value: f64 = 0, /// For cd_matured / cd_removed_early: maturity_date. maturity_date: ?Date = null, /// For price_only: old and new values. old_price: f64 = 0, new_price: f64 = 0, /// Free-form detail for flagged changes. detail: ?[]const u8 = null, /// Lot open_date for new_* kinds — carried here so downstream /// consumers (the audit large-lot warning, the Transfers section /// printer) can generate `transfer_log.srf` templates without /// re-reading the after-portfolio. Null for non-new kinds. open_date: ?Date = null, // ── Transfer reclassification (see `matchTransfers`) ──── /// Dollar amount of this Change's `value()` that's attributable /// to a matched transfer record. Set only for `transfer_in` /// (equals `value()`), `partial_transfer_in` (less than `value()`), /// and `unmatched_transfer` (equals the record's amount, carried /// on a synthetic Change). Zero otherwise. transfer_attributed: f64 = 0, /// Free-form text carried from the transfer record (its `note::` /// field) or from the matcher (for `unmatched_transfer`, a /// reason string). transfer_note: ?[]const u8 = null, /// Sending account for transfer_in / partial_transfer_in / /// unmatched_transfer. Null for non-transfer kinds. transfer_from: ?[]const u8 = null, /// Date of the matched transfer record. Null for non-transfer /// kinds. transfer_date: ?Date = null, pub fn value(self: Change) f64 { return self.delta_shares * self.unit_value; } /// Portion of `value()` that counts toward attribution (after /// transfer reclassification). For `transfer_in` / `transfer_out` /// / `unmatched_transfer`, the contribution is $0. For /// `partial_transfer_in`, only the residual counts. For /// `new_cash`, `cash_contribution`, and `cash_delta`, the /// matcher may have credited some of the value to a cash- /// destination transfer record (see `matchCashDestination`); /// `transfer_attributed` tracks how much. The residual is /// `value() − transfer_attributed`, which is what shows up in /// "New contributions / purchases" and the audit large-lot /// filter. Everyone else sees `value()` unchanged. pub fn attributedValue(self: Change) f64 { return switch (self.kind) { .transfer_in, .transfer_out, .unmatched_transfer => 0, .partial_transfer_in => self.value() - self.transfer_attributed, .new_cash, .cash_contribution, .cash_delta => self.value() - self.transfer_attributed, else => self.value(), }; } }; /// Summary aggregated for the report. All string fields and backing memory /// live in the caller-supplied arena; there is no explicit deinit. const Report = struct { changes: []Change, /// Per-account rollups for the summary section. account_totals: std.StringHashMap(AccountTotal), /// Per-account cash amounts matched to transfer records. Subtracted /// from cash-side totals in the per-account summary so transferred /// cash doesn't double-count. Keys borrow from Change.account /// strings (arena-owned, same lifetime as the Report). cash_attributed_by_account: std.StringHashMap(f64), const AccountTotal = struct { new_money: f64 = 0, // stock+cd+cash new lots (drip::false) drip_confirmed: f64 = 0, // confirmed DRIP (drip::true lots: new or shares increased) rollup: f64 = 0, // share deltas on drip::false aggregate lots (DRIP or contribution; ambiguous) cd_interest: f64 = 0, // implied interest from matured CDs cash_delta: f64 = 0, // unclassified cash balance changes }; }; /// Build a canonical lookup key for matching lots between snapshots. /// Key: (security_type, symbol, account, open_date, open_price). fn lotKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 { return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{f}|{d:.6}", .{ @tagName(lot.security_type), lot.symbol, lot.account orelse "", lot.open_date, lot.open_price, }); } /// Aggregate duplicate-key lots by summing shares. (Rare in practice but /// possible.) Returns map key -> (shares, representative Lot). const LotAgg = struct { shares: f64, lot: Lot, }; fn aggregateByKey( allocator: std.mem.Allocator, lots: []const Lot, ) !std.StringHashMap(LotAgg) { var map = std.StringHashMap(LotAgg).init(allocator); for (lots) |lot| { const k = try lotKey(allocator, lot); const gop = try map.getOrPut(k); if (gop.found_existing) { gop.value_ptr.shares += lot.shares; } else { gop.value_ptr.* = .{ .shares = lot.shares, .lot = lot }; } } return map; } /// Secondary key for edit detection: (security_type, priceSymbol, account). /// Lots with the same secondary key but different strict `lotKey`s are /// candidates for reclassification as `lot_edited` — the strict key /// broke because `open_date`, `open_price`, or the underlying symbol /// string got rewritten, but the position itself continued. /// /// Uses `priceSymbol()` (ticker-alias-aware) rather than raw `symbol` /// so that edits like `symbol::SPY` → `symbol::DI-SPX, ticker::SPY` /// collapse correctly. Both sides resolve to the same effective /// ticker (`SPY`) and represent the same underlying exposure; the /// raw `symbol` changed but the position did not. fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 { return std.fmt.allocPrint(allocator, "{s}|{s}|{s}", .{ @tagName(lot.security_type), lot.priceSymbol(), lot.account orelse "", }); } /// Tolerance for "did the share total stay the same" check when /// deciding whether to emit a rollup_delta for any residual share /// difference alongside a `lot_edited` classification. Anything within /// this tolerance is treated as rounding/reconciliation noise and /// suppressed; anything beyond it surfaces as a normal share-delta /// change (rollup_delta for positive, drip_negative for negative). /// /// This is NOT a gate on whether the pair collapses — secondary-key /// matches ALWAYS collapse the identity change into a lot_edited /// record. The tolerance only decides whether to additionally emit /// the residual share movement as a distinct change. const edit_residual_tolerance_rel: f64 = 0.0001; // 0.01% /// Looser tolerance applied to accounts flagged /// `direct_indexing::true` in `accounts.srf`. Direct-indexing /// proxies (a basket of underlying stocks tracked as a single /// benchmark via `ticker::`) have tracking-error drift that legitimately /// moves the basket's equivalent share count without any real money /// flowing. A tighter tolerance flags that drift as contribution- /// adjacent noise; this looser one treats it as edit-only, so the /// attribution line stays clean of tracking-error reconciliation. /// /// 1% is chosen to sit well above typical weekly tracking-error /// magnitudes (< 0.5% in normal markets) while still catching /// out-of-band moves — e.g. an actual $100k contribution into a /// multi-million direct-indexing basket (~1.2% of the account) will /// fall just over this threshold and surface as a rollup_delta for /// review. Not configurable per-account today; revisit if anyone's /// direct-indexing account generates > 1% drift regularly. const direct_indexing_residual_tolerance_rel: f64 = 0.01; // 1% /// Identify strict-key pairs that should be reclassified as edits. /// /// Walks the "only in after" and "only in before" strict keys, groups /// each by secondary key (security_type, priceSymbol, account), and /// returns a set of strict keys that should be skipped by the primary /// classification passes (both `new_*` and `lot_removed` / `cd_*`). /// Populates `changes` with a single `lot_edited` entry per matched /// secondary-key group, plus a residual `rollup_delta` / `drip_negative` /// for any share difference beyond noise tolerance. /// /// Matching rules: /// - A secondary key must have at least one unmatched lot on each /// side (otherwise it's a pure add or a pure remove, which keeps /// the existing semantics). /// - All unmatched strict keys for that secondary key group collapse /// together regardless of share-total magnitude. The thinking: /// secondary-key agreement (same `(security_type, priceSymbol, /// account)`) means the user is editing the same underlying /// position. Any share difference is a separate question — /// handled by emitting a residual rollup_delta (if positive) or /// drip_negative (if negative). This matches how share deltas on /// intact strict keys are classified in Pass 1. fn detectEdits( allocator: std.mem.Allocator, before_map: *const std.StringHashMap(LotAgg), after_map: *const std.StringHashMap(LotAgg), prices: *const std.StringHashMap(f64), account_map: ?*const analysis.AccountMap, changes: *std.ArrayList(Change), ) !std.StringHashMap(void) { var skip = std.StringHashMap(void).init(allocator); errdefer skip.deinit(); // Group unmatched strict keys by secondary key, tagging each entry // with the side it came from. `strict_keys` alongside the value // lets us seed the skip set only when a group actually matches. const SidedEntry = struct { strict_key: []const u8, agg: LotAgg, from_after: bool, }; var groups = std.StringHashMap(std.ArrayList(SidedEntry)).init(allocator); defer { var vit = groups.valueIterator(); while (vit.next()) |list| list.deinit(allocator); groups.deinit(); } var ait = after_map.iterator(); while (ait.next()) |entry| { if (before_map.contains(entry.key_ptr.*)) continue; const sk = try secondaryKey(allocator, entry.value_ptr.*.lot); const gop = try groups.getOrPut(sk); if (!gop.found_existing) { gop.value_ptr.* = std.ArrayList(SidedEntry).empty; } else { allocator.free(sk); } try gop.value_ptr.append(allocator, .{ .strict_key = entry.key_ptr.*, .agg = entry.value_ptr.*, .from_after = true, }); } var bit = before_map.iterator(); while (bit.next()) |entry| { if (after_map.contains(entry.key_ptr.*)) continue; const sk = try secondaryKey(allocator, entry.value_ptr.*.lot); const gop = try groups.getOrPut(sk); if (!gop.found_existing) { gop.value_ptr.* = std.ArrayList(SidedEntry).empty; } else { allocator.free(sk); } try gop.value_ptr.append(allocator, .{ .strict_key = entry.key_ptr.*, .agg = entry.value_ptr.*, .from_after = false, }); } // Dup helper for string arena storage into `changes`. const Dup = struct { a: std.mem.Allocator, fn of(self: @This(), s: []const u8) ![]const u8 { return self.a.dupe(u8, s); } }; const sdup = Dup{ .a = allocator }; var git_it = groups.iterator(); while (git_it.next()) |entry| { const list = entry.value_ptr.*; // Need at least one on each side to be an edit. var after_shares: f64 = 0; var before_shares: f64 = 0; var after_rep: ?LotAgg = null; var before_rep: ?LotAgg = null; for (list.items) |e| { if (e.from_after) { after_shares += e.agg.shares; if (after_rep == null) after_rep = e.agg; } else { before_shares += e.agg.shares; if (before_rep == null) before_rep = e.agg; } } if (after_shares == 0 or before_shares == 0) continue; // Qualified edit: add all strict keys to the skip set. Emit a // lot_edited record representing the identity continuity. for (list.items) |e| { try skip.put(e.strict_key, {}); } const rep_lot = after_rep.?.lot; const acct = try sdup.of(rep_lot.account orelse ""); const sym = try sdup.of(rep_lot.symbol); try changes.append(allocator, .{ .kind = .lot_edited, .symbol = sym, .account = acct, .security_type = rep_lot.security_type, .delta_shares = 0, .unit_value = 0, }); // Emit a residual share-delta change if the totals diverge // beyond noise. Mirrors Pass 1's same-key share-delta handling // so the user sees the same classification whether the strict // key was preserved or rewritten. // // Direct-indexing accounts (flagged `direct_indexing::true` // in accounts.srf) use a looser tolerance — tracking-error // share reconciliation on a proxy basket isn't real money // flow and shouldn't land in rollup_delta / drip_negative. const delta = after_shares - before_shares; const denom = @max(@abs(after_shares), @abs(before_shares)); const rel = if (denom == 0) 0.0 else @abs(delta) / denom; const tolerance = if (account_map) |am| (if (am.isDirectIndexing(rep_lot.account orelse "")) direct_indexing_residual_tolerance_rel else edit_residual_tolerance_rel) else edit_residual_tolerance_rel; if (rel <= tolerance) continue; const unit_value: f64 = blk: { if (rep_lot.security_type == .stock) { if (prices.get(rep_lot.priceSymbol())) |p| break :blk p * rep_lot.price_ratio; if (rep_lot.price) |p| break :blk p * rep_lot.price_ratio; break :blk rep_lot.open_price * rep_lot.price_ratio; } break :blk 1.0; }; const is_drip = rep_lot.drip or (before_rep.?.lot.drip); const kind: ChangeKind = switch (rep_lot.security_type) { .stock => if (delta > 0) (if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta) else ChangeKind.drip_negative, .cash => .cash_delta, else => .flagged, }; try changes.append(allocator, .{ .kind = kind, .symbol = sym, .account = acct, .security_type = rep_lot.security_type, .delta_shares = delta, .unit_value = unit_value, }); } return skip; } /// Optional knobs for `computeReport`. An explicit struct keeps the /// call-site noise low at the many in-file test call sites (all pass /// `.{}`) while still letting production callers thread through the /// account map for opted-in cash-delta reclassification. const ReportOptions = struct { /// Account metadata from `accounts.srf`. When present, positive /// `cash_delta` entries for accounts with /// `cash_is_contribution::true` are reclassified as /// `cash_contribution` so every downstream consumer (full /// report, per-account summary, `compare` attribution) agrees. account_map: ?*const analysis.AccountMap = null, /// Transfer records to match against this diff. Typically the /// records that are NEW in the after-side `transaction_log.srf` /// relative to the before-side (computed by /// `diffTransferLogs`). The matcher reclassifies /// destination/source Changes as `transfer_in` / /// `partial_transfer_in` / `transfer_out` and emits /// `unmatched_transfer` entries for records that can't be /// matched. When null or empty, `matchTransfers` is a no-op. /// /// Records are not date-window filtered: the caller is /// responsible for passing only records that should be /// considered for THIS diff. The expected source is "records /// added to `transaction_log.srf` since `before_rev`," not /// "records dated within the diff's git timestamp window." /// See `diffTransferLogs` and the prepareReport pipeline for /// how the production caller assembles the slice. transfer_log: ?[]const transaction_log.TransferRecord = null, }; fn computeReport( allocator: std.mem.Allocator, before: []const Lot, after: []const Lot, prices: *const std.StringHashMap(f64), as_of: Date, opts: ReportOptions, ) !Report { var changes: std.ArrayList(Change) = .empty; var before_map = try aggregateByKey(allocator, before); var after_map = try aggregateByKey(allocator, after); // Edit detection: identify strict-key pairs that look like edits // (reconciliation tweak, CD auto-renewal rewriting `open_date`, // symbol alias rewrite like SPY→DI-SPX,ticker::SPY) rather than // real new/removed lots. The returned `skip` set is the list of // strict keys passes 1 and 2 should ignore — each matched group // emits its own `lot_edited` change plus a residual rollup for // any share delta beyond noise. var skip = try detectEdits(allocator, &before_map, &after_map, prices, opts.account_map, &changes); defer skip.deinit(); // Helper for duping strings into the arena so Change fields have // predictable lifetimes even if caller-supplied lot strings go away. const Dup = struct { a: std.mem.Allocator, fn of(self: @This(), s: []const u8) ![]const u8 { return self.a.dupe(u8, s); } }; const sdup = Dup{ .a = allocator }; // Pass 1: keys in after. Classify as new, shares-changed, or (matched // with price-only or other-metadata changes) edited. var ait = after_map.iterator(); while (ait.next()) |entry| { if (skip.contains(entry.key_ptr.*)) continue; const after_agg = entry.value_ptr.*; if (before_map.get(entry.key_ptr.*)) |before_agg| { // Key present in both. Compare shares and other fields. const delta = after_agg.shares - before_agg.shares; const lot = after_agg.lot; const acct = try sdup.of(lot.account orelse ""); const sym = try sdup.of(lot.symbol); if (@abs(delta) > 0.000001) { // Direct-indexing suppression: for stock lots in // flagged accounts, sub-1% share drift is tracking- // error reconciliation, not real money flow. Skip // emitting a rollup_delta / drip_negative so the // attribution stays clean. Must apply the same // logic here as in `detectEdits` or the treatment // becomes inconsistent depending on whether the // strict key broke this week. if (lot.security_type == .stock) { const denom = @max(@abs(after_agg.shares), @abs(before_agg.shares)); const rel = if (denom == 0) 0.0 else @abs(delta) / denom; const is_di = if (opts.account_map) |am| am.isDirectIndexing(lot.account orelse "") else false; if (is_di and rel <= direct_indexing_residual_tolerance_rel) continue; } const before_lot = before_agg.lot; const is_drip = lot.drip or before_lot.drip; const base_kind: ChangeKind = switch (lot.security_type) { .stock => if (delta > 0) (if (is_drip) ChangeKind.drip_confirmed else ChangeKind.rollup_delta) else ChangeKind.drip_negative, .cash => .cash_delta, .cd => .flagged, // CD face value shouldn't change on the same key .option => .flagged, else => .flagged, }; // Opt-in reclassification: on accounts marked // `cash_is_contribution::true` in accounts.srf, a // positive cash_delta is actually new money arriving // (payroll ESPP accrual, direct 401k cash deposit, // etc.). Reclassify at this single point so every // downstream consumer — full report, per-account // summary, `compare` attribution — sees the same // classification. Negative cash_delta stays as noise // (a real withdrawal would need different semantics). const kind: ChangeKind = if (base_kind == .cash_delta and delta > 0) blk: { if (opts.account_map) |am| { if (am.cashIsContribution(lot.account orelse "")) break :blk .cash_contribution; } break :blk .cash_delta; } else base_kind; // Determine unit_value for stocks: prefer current cached price; // fall back to manual price::; fall back to open_price. const unit_value: f64 = blk: { if (lot.security_type == .stock) { if (prices.get(lot.priceSymbol())) |p| break :blk p * lot.price_ratio; if (lot.price) |p| break :blk p * lot.price_ratio; break :blk lot.open_price * lot.price_ratio; } // cash/cd: 1:1 with shares break :blk 1.0; }; try changes.append(allocator, .{ .kind = kind, .symbol = sym, .account = acct, .security_type = lot.security_type, .delta_shares = delta, .unit_value = unit_value, }); } else { // Same shares. Check for price:: field or other metadata changes. const before_lot = before_agg.lot; const a_price = lot.price; const b_price = before_lot.price; if ((a_price != null) != (b_price != null) or (a_price != null and b_price != null and @abs(a_price.? - b_price.?) > 0.000001)) { try changes.append(allocator, .{ .kind = .price_only, .symbol = sym, .account = acct, .security_type = lot.security_type, .old_price = b_price orelse 0, .new_price = a_price orelse 0, }); } else if ((lot.maturity_date == null) != (before_lot.maturity_date == null) or (lot.maturity_date != null and before_lot.maturity_date != null and !lot.maturity_date.?.eql(before_lot.maturity_date.?))) { var old_buf: [10]u8 = undefined; var new_buf: [10]u8 = undefined; const old_str = if (before_lot.maturity_date) |d| (std.fmt.bufPrint(&old_buf, "{f}", .{d}) catch "????-??-??") else "(none)"; const new_str = if (lot.maturity_date) |d| (std.fmt.bufPrint(&new_buf, "{f}", .{d}) catch "????-??-??") else "(none)"; const detail = try std.fmt.allocPrint(allocator, "maturity_date {s} -> {s}", .{ old_str, new_str }); try changes.append(allocator, .{ .kind = .flagged, .symbol = sym, .account = acct, .security_type = lot.security_type, .detail = detail, }); } // Other edits (note, rate, etc.) are intentionally ignored to // keep noise low. } } else { // Key only in after → new lot. const lot = after_agg.lot; const acct = try sdup.of(lot.account orelse ""); const sym = try sdup.of(lot.symbol); const kind: ChangeKind = switch (lot.security_type) { .stock => if (lot.drip) ChangeKind.new_drip_lot else ChangeKind.new_stock, .cash => .new_cash, .cd => .new_cd, .option => .new_option, else => .flagged, }; // For fresh stock lots: value at open_price (that's literally the // money that went in). For cash: shares == dollars. For CDs: // face = shares × open_price. const unit_value: f64 = switch (lot.security_type) { .stock => lot.open_price * lot.price_ratio, .cash => 1.0, .cd => lot.open_price, .option => lot.open_price * lot.multiplier, else => lot.open_price, }; try changes.append(allocator, .{ .kind = kind, .symbol = sym, .account = acct, .security_type = lot.security_type, .delta_shares = after_agg.shares, .unit_value = unit_value, .open_date = lot.open_date, }); } } // Pass 2: keys in before but not in after → lot disappeared. var bit = before_map.iterator(); while (bit.next()) |entry| { if (after_map.contains(entry.key_ptr.*)) continue; if (skip.contains(entry.key_ptr.*)) continue; const before_agg = entry.value_ptr.*; const lot = before_agg.lot; const acct = try sdup.of(lot.account orelse ""); const sym = try sdup.of(lot.symbol); var kind: ChangeKind = .lot_removed; if (lot.security_type == .cd) { if (lot.maturity_date) |mat| { // "matured" if maturity_date <= as_of (i.e. NOT as_of.lessThan(mat)) if (!as_of.lessThan(mat)) { kind = .cd_matured; } else { kind = .cd_removed_early; } } else { kind = .cd_removed_early; // no maturity — treat as flagged-ish } } try changes.append(allocator, .{ .kind = kind, .symbol = sym, .account = acct, .security_type = lot.security_type, .face_value = before_agg.shares * (if (lot.security_type == .cd) lot.open_price else 1.0), .maturity_date = lot.maturity_date, .delta_shares = -before_agg.shares, }); } // Transfer reclassification pass: rewrite destination/source // Change kinds for records the caller passed in (typically the // diff between before-side and after-side // `transaction_log.srf`), and accumulate per-account cash // attribution so transferred cash doesn't double-count in // per-account totals. No-op when no records are supplied. See // `matchTransfers` docstring for the matching algorithm. var cash_attributed_by_account: std.StringHashMap(f64) = .init(allocator); if (opts.transfer_log) |records| { try matchTransfers( allocator, &changes, &cash_attributed_by_account, records, ); } // Build per-account totals. var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator); for (changes.items) |c| { const gop = try acct_totals.getOrPut(c.account); if (!gop.found_existing) gop.value_ptr.* = .{}; switch (c.kind) { .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => { // attributedValue() returns the unattributed residual // for cash-kind Changes (matchCashDestination drained // `transfer_attributed`); for non-cash kinds it equals // value(). Either way, this is "real new money on the // account." gop.value_ptr.new_money += c.attributedValue(); }, .new_drip_lot, .drip_confirmed => { gop.value_ptr.drip_confirmed += c.value(); }, .rollup_delta => { gop.value_ptr.rollup += c.value(); }, .cash_delta => { gop.value_ptr.cash_delta += c.attributedValue(); }, .cd_matured => { // Interest is computed lazily against cash_delta in print(); // we don't add face value to new_money (that's not new money). }, .partial_transfer_in => { // Residual (value() − transfer_attributed) flows into // new_money. The lot-destination matcher is the only // producer of partial_transfer_in (cash-dest uses the // per-account attribution bucket), so the residual // represents pre-existing cash that funded part of // the lot — a real contribution from the user. gop.value_ptr.new_money += c.attributedValue(); }, .transfer_in, .transfer_out, .unmatched_transfer => { // $0 contribution. Deliberately no-op. }, else => {}, } } // Note: cash-dest transfer attribution is already removed by // `attributedValue()` on each cash-side Change (the matcher // accumulates into `transfer_attributed`). No second subtraction // off `cash_attributed_by_account` needed here. The bucket is // still populated for downstream consumers (e.g. callers that // want a per-account view of attributed transfers) but isn't // used in the totals math. return .{ .changes = try changes.toOwnedSlice(allocator), .account_totals = acct_totals, .cash_attributed_by_account = cash_attributed_by_account, }; } // ── Transfer reclassification ──────────────────────────────── /// Absolute-dollar tolerance for matching a transfer record's `amount` /// against a Change's `value()`. Within ±tolerance is a full match /// (`transfer_in`); strictly below is a partial (`partial_transfer_in`); /// strictly above is unmatched (with "amount exceeds …" detail). /// /// $1 is chosen to absorb typical reconciliation rounding (broker /// statements often show cents, portfolio.srf may round to whole /// dollars for cash lots) without masking real discrepancies. const transfer_amount_tolerance: f64 = 1.0; /// Compute the slice of transfer records that are NEW in `after` /// relative to `before` — i.e. records the matcher should consider /// for the current diff. Records present in both logs are skipped /// (they paired in their own diff cycle and shouldn't re-match). /// /// `before` may be null (e.g. `transaction_log.srf` did not exist in /// the before-side revision); in that case every record in `after` /// is treated as new. /// /// The returned slice is allocated in `arena` and its records /// borrow from `after`'s record memory. Treat the slice as read-only /// and tied to `after`'s lifetime. /// /// Equality is `TransferRecord.eql` (total-field equality including /// optional `note`). User edits to an existing record's any field /// produce a "new" record from this function's POV; the matcher /// then re-attempts pairing. See the `eql` doc comment for rationale. fn diffTransferLogs( arena: std.mem.Allocator, before: ?*const transaction_log.TransactionLog, after: *const transaction_log.TransactionLog, ) ![]const transaction_log.TransferRecord { var out: std.ArrayList(transaction_log.TransferRecord) = .empty; errdefer out.deinit(arena); for (after.transfers) |a| { var found = false; if (before) |b| for (b.transfers) |bef| { if (a.eql(bef)) { found = true; break; } }; if (!found) try out.append(arena, a); } return try out.toOwnedSlice(arena); } /// Reclassify Changes whose lot (or cash_delta) corresponds to a /// recorded transfer. Walks `report.changes` and the in-window /// transfer records together: /// /// - For each record with `type::cash` (v1 only; `in_kind` always /// emits `unmatched_transfer`): /// - If `dest_lot` is a specific lot (`SYMBOL@DATE`): find the /// matching `new_stock` / `new_drip_lot` / `new_cash` / /// `new_cd` / `cash_contribution` Change with the same /// (account, symbol) and flip its kind to `transfer_in` (full) /// or `partial_transfer_in` (partial), recording /// `transfer_attributed` / `transfer_note`. Emit /// `unmatched_transfer` if no such Change exists, if another /// record already consumed it, or if `amount` exceeds the /// lot's value by more than tolerance. /// - If `dest_lot::cash`: verify the `to` account's pooled /// positive cash activity (new_cash + cash_delta + /// cash_contribution summed) can cover the record's amount /// (minus any prior cash-dest records on the same account). /// Success appends a synthetic `transfer_in` Change for /// display AND accumulates into /// `cash_attributed_by_account[to]`, which the caller /// subtracts from cash-side per-account totals. Failure /// (budget underflow) emits `unmatched_transfer`. /// /// - For the `from` side: try to find a matching negative /// `cash_delta` or `lot_removed` on the sending account and /// credit the transfer amount against it, flipping to /// `transfer_out`. A missing `from` side is NOT unmatched — the /// sending account might not appear in portfolio.srf at all /// (external account) or its outflow may be masked by unrelated /// activity (dividend posting offsetting the withdrawal). Only /// destination mismatches surface as unmatched_transfer. /// /// Records outside the window (`transfer < window_start` or /// `transfer > window_end`) are NOT filtered here. The caller is /// responsible for narrowing the slice to records that should be /// considered for THIS diff. The production path uses /// `diffTransferLogs` to pass only the records added to /// `transaction_log.srf` since `before_rev`, regardless of their /// `transfer::DATE`. This allows a user to back-date a record /// (e.g. add a `transfer::2026-05-20` entry on 2026-05-23) and /// have it pair against the working-copy diff that introduced it. /// /// Populates `cash_attributed_by_account` (caller-owned) with the /// per-account total of amounts matched to cash-destination records; /// these amounts are subtracted from the cash bucket in the /// per-account totals pass so transferred cash doesn't double-count. fn matchTransfers( allocator: std.mem.Allocator, changes: *std.ArrayList(Change), cash_attributed_by_account: *std.StringHashMap(f64), records: []const transaction_log.TransferRecord, ) !void { // Bookkeeping: track which Change indices have already been // claimed by a transfer record to catch duplicates on the // lot-destination path. var consumed_lot_idx: std.AutoHashMap(usize, void) = .init(allocator); defer consumed_lot_idx.deinit(); // Per-account cash budget: sum of positive cash activity // (new_cash + positive cash_delta + cash_contribution) on each // account. Cash-dest records draw from this pool in record // order. Underflow past tolerance → unmatched_transfer. var cash_budget: std.StringHashMap(f64) = .init(allocator); defer cash_budget.deinit(); for (changes.items) |c| { const v = c.value(); switch (c.kind) { .new_cash => { const gop = try cash_budget.getOrPut(c.account); if (!gop.found_existing) gop.value_ptr.* = 0; gop.value_ptr.* += v; }, .cash_delta, .cash_contribution => if (v > 0) { const gop = try cash_budget.getOrPut(c.account); if (!gop.found_existing) gop.value_ptr.* = 0; gop.value_ptr.* += v; }, else => {}, } } // Walk transfer records in caller-supplied order. Caller is // responsible for filtering to only those records that should // be considered for THIS diff (typically: records new in the // after-side `transaction_log.srf` vs. the before-side). for (records) |rec| { if (rec.type == .in_kind) { try appendUnmatched(allocator, changes, rec, "in-kind transfers not yet supported in v1"); continue; } switch (rec.dest_lot) { .lot => |dl| { try matchLotDestination(allocator, changes, &consumed_lot_idx, rec, dl); }, .cash => { try matchCashDestination(allocator, changes, &cash_budget, cash_attributed_by_account, rec); }, } tryMatchFromSide(changes, rec); } } /// Append a synthetic `unmatched_transfer` Change carrying the record's /// raw amount + from/to + date + a reason string. Detail lives on /// `transfer_note`; `amount` encodes into `delta_shares * unit_value` /// as `amount * 1.0` so `value()` returns the transfer amount. fn appendUnmatched( allocator: std.mem.Allocator, changes: *std.ArrayList(Change), rec: transaction_log.TransferRecord, reason: []const u8, ) !void { const from = try allocator.dupe(u8, rec.from); const to = try allocator.dupe(u8, rec.to); const note = try allocator.dupe(u8, reason); try changes.append(allocator, .{ .kind = .unmatched_transfer, .symbol = "", .account = to, // "landed on" side for the Flagged display .security_type = .cash, // irrelevant — no lot attached .delta_shares = rec.amount, .unit_value = 1.0, .transfer_attributed = rec.amount, .transfer_note = note, .transfer_from = from, .transfer_date = rec.transfer, }); } /// Find the Change matching `dest_lot` and flip its kind. Produces /// an `unmatched_transfer` on any mismatch (not found, already /// consumed, amount too big). fn matchLotDestination( allocator: std.mem.Allocator, changes: *std.ArrayList(Change), consumed: *std.AutoHashMap(usize, void), rec: transaction_log.TransferRecord, dl: transaction_log.DestLot.LotRef, ) !void { // Find the first matching Change by (account, symbol, open_date) // that's: // - A destination-side kind (new_* or cash_contribution on the // `to` account); transfer_in / partial already consumed // counts as "taken". // - Not yet claimed by another transfer record. // // We don't have direct access to the underlying Lot's open_date // on the Change struct — it's encoded implicitly in the diff // (a `new_stock` Change has one lot on the `after` side with a // unique open_date). Since the matcher can't disambiguate // multiple lots of the same (account, symbol) opened on // different dates, we leave the matching keyed just on // (account, symbol) and accept the rare ambiguity. When two // lots of the same (account, symbol) appear in the same diff, // the user would need separate transfer records per lot — the // first record matches the first Change, the second matches the // second, etc. // // TODO: thread open_date through Change. For now, accept the // (account, symbol) key and first-match-wins. _ = dl.open_date; // consulted only for display (see report printer) var found_idx: ?usize = null; var saw_consumed = false; for (changes.items, 0..) |c, i| { if (!std.mem.eql(u8, c.account, rec.to)) continue; if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue; const is_dest_kind = switch (c.kind) { .new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true, else => false, }; if (!is_dest_kind) continue; if (consumed.contains(i)) { saw_consumed = true; continue; } found_idx = i; break; } if (found_idx) |i| { const c = &changes.items[i]; const lot_value = c.value(); if (rec.amount > lot_value + transfer_amount_tolerance) { // Amount exceeds lot by more than tolerance: emit // unmatched, leave the Change untouched. const buf = try std.fmt.allocPrint( allocator, "amount ${d:.2} exceeds destination lot {s}@ value ${d:.2}", .{ rec.amount, dl.symbol, lot_value }, ); try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf); return; } try consumed.put(i, {}); // Copy the note from the record (if any) onto the Change. const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null; const from_copy = try allocator.dupe(u8, rec.from); if (@abs(rec.amount - lot_value) <= transfer_amount_tolerance) { c.kind = .transfer_in; c.transfer_attributed = lot_value; // full } else { c.kind = .partial_transfer_in; c.transfer_attributed = rec.amount; // residual = value() − amount } c.transfer_note = note_copy; c.transfer_from = from_copy; c.transfer_date = rec.transfer; } else if (saw_consumed) { const buf = try std.fmt.allocPrint( allocator, "destination lot {s}@ on account {s} already claimed by an earlier transfer record", .{ dl.symbol, rec.to }, ); try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf); } else { const buf = try std.fmt.allocPrint( allocator, "destination lot {s}@ not found on account {s}", .{ dl.symbol, rec.to }, ); try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf); } } /// Same as `appendUnmatched` but takes a pre-formatted note the /// caller already owns (produced via `std.fmt.allocPrint`). fn appendUnmatchedWithOwnedNote( allocator: std.mem.Allocator, changes: *std.ArrayList(Change), rec: transaction_log.TransferRecord, owned_note: []const u8, ) !void { const from = try allocator.dupe(u8, rec.from); const to = try allocator.dupe(u8, rec.to); try changes.append(allocator, .{ .kind = .unmatched_transfer, .symbol = "", .account = to, .security_type = .cash, .delta_shares = rec.amount, .unit_value = 1.0, .transfer_attributed = rec.amount, .transfer_note = owned_note, .transfer_from = from, .transfer_date = rec.transfer, }); } /// Verify the `to` account's cash budget has capacity for this /// record, draw from it, and either attach to an existing cash /// Change or append a synthetic one. The per-account attribution /// bucket (`cash_attributed_by_account`) is what actually drives /// totals math — the Change-level reclassification is for display. fn matchCashDestination( allocator: std.mem.Allocator, changes: *std.ArrayList(Change), cash_budget: *std.StringHashMap(f64), cash_attributed_by_account: *std.StringHashMap(f64), rec: transaction_log.TransferRecord, ) !void { const budget_entry = cash_budget.getPtr(rec.to); const available = if (budget_entry) |p| p.* else 0.0; if (available < rec.amount - transfer_amount_tolerance) { const buf = try std.fmt.allocPrint( allocator, "destination cash increase ${d:.2} insufficient for transfer ${d:.2}", .{ available, rec.amount }, ); try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf); return; } // Draw from the budget. Running remainder stays on the budget // so later records on the same account see the correct // capacity. if (budget_entry) |p| p.* -= rec.amount; // Accumulate into per-account attribution bucket. The per- // account totals pass subtracts this from cash-side totals so // transferred cash doesn't double-count. const gop = try cash_attributed_by_account.getOrPut(rec.to); if (!gop.found_existing) gop.value_ptr.* = 0; gop.value_ptr.* += rec.amount; // Distribute the record amount across the destination account's // cash-side Changes by accumulating into each Change's // `transfer_attributed`. We deliberately do NOT flip the // Change's `kind`: a single cash Change can be drained by // multiple records (e.g. two transfers landing on the same // cash lot), and a single record can drain across multiple // cash Changes (when the user split the inflow into several // lots). The kind field can only hold one classification. // // Bumping `transfer_attributed` makes `attributedValue()` // return the unattributed residual — fully-attributed Changes // drop out of the "New contributions" section, audit's // "Large new lots" filter, and any other consumer that asks // "how much of this Change is real new money?". The summary // pass continues to use `cash_attributed_by_account` for its // per-account math — the two views agree because the same // amount is subtracted on both sides. var remaining = rec.amount; for (changes.items) |*c| { if (remaining <= 0) break; if (!std.mem.eql(u8, c.account, rec.to)) continue; const is_cash_kind = switch (c.kind) { .new_cash, .cash_contribution => true, .cash_delta => c.value() > 0, else => false, }; if (!is_cash_kind) continue; const unattributed = c.value() - c.transfer_attributed; if (unattributed <= 0) continue; const draw = @min(unattributed, remaining); c.transfer_attributed += draw; remaining -= draw; } // `remaining > 0` here would indicate the budget pre-check // accepted a record we couldn't actually distribute. Possible // if the cash-side Changes' `value()` totals don't agree with // the `cash_budget` we built (they should — both are derived // from the same Changes). Leave it as silent over-credit on // the per-account bucket; the user-visible symptom would be // a small Joint-trust-style residual showing up in audit. // Append a synthetic transfer_in Change for Transfers-section // display. const from = try allocator.dupe(u8, rec.from); const to = try allocator.dupe(u8, rec.to); const note_copy: ?[]const u8 = if (rec.note) |n| try allocator.dupe(u8, n) else null; try changes.append(allocator, .{ .kind = .transfer_in, .symbol = "", .account = to, .security_type = .cash, .delta_shares = rec.amount, .unit_value = 1.0, .transfer_attributed = rec.amount, .transfer_note = note_copy, .transfer_from = from, .transfer_date = rec.transfer, }); } /// Best-effort: find a negative cash_delta or lot_removed on the /// `from` account with |value| >= amount − tolerance; reclassify to /// transfer_out. No-op (silent) if no such Change exists — the /// sending side may not be in portfolio.srf. fn tryMatchFromSide( changes: *std.ArrayList(Change), rec: transaction_log.TransferRecord, ) void { for (changes.items) |*c| switch (c.kind) { .cash_delta, .lot_removed => { if (!std.mem.eql(u8, c.account, rec.from)) continue; const abs_val = @abs(c.value()); if (abs_val < rec.amount - transfer_amount_tolerance) continue; // Match: flip to transfer_out. We don't track // transfer_attributed on the from side (it's // decorative) but we record the counterpart date so // the Transfers section can cross-reference. c.kind = .transfer_out; c.transfer_attributed = rec.amount; c.transfer_date = rec.transfer; return; }, else => {}, }; } // ── Output ─────────────────────────────────────────────────── fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void { const h_color = cli.CLR_HEADER; const pos_color = cli.CLR_POSITIVE; const mut_color = cli.CLR_MUTED; const warn_color = cli.CLR_WARNING; // Header try cli.setBold(out, color); try cli.printFg(out, color, h_color, "Portfolio contributions report\n", .{}); try cli.setFg(out, color, mut_color); try out.writeAll(" "); try out.writeAll(label); try out.writeAll("\n\n"); try cli.reset(out, color); // If nothing changed at all, say so explicitly and return. if (report.changes.len == 0) { try cli.printFg(out, color, mut_color, " No changes detected.\n", .{}); return; } // ── Section: New contributions / purchases ── try printSection(out, "New contributions / purchases", color, h_color); var any = false; var new_total: f64 = 0; for (report.changes) |c| switch (c.kind) { .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => { // Use attributedValue so a cash lot fully covered by a // transfer record (whose `transfer_attributed` equals // its `value()`) drops out, and a partially-covered // lot shows only the unattributed remainder. const residual = c.attributedValue(); if (residual <= 0) continue; any = true; new_total += residual; try printChangeLine(out, c, color, pos_color); }, .partial_transfer_in => { // Lot partially funded by a transfer: show the residual // (value() − transfer_attributed) in this section, with // the full lot value annotated so the user can see where // the transferred portion went. const residual = c.attributedValue(); if (residual > 0) { any = true; new_total += residual; try printPartialTransferLine(out, c, color, pos_color, mut_color); } }, else => {}, }; if (!any) try printNone(out, color, mut_color); if (any) try printTotalLine(out, "Total", new_total, color, h_color); try out.writeAll("\n"); // ── Section: DRIP (confirmed) ── try printSection(out, "DRIP (confirmed — lots tagged drip::true)", color, h_color); any = false; var drip_total: f64 = 0; for (report.changes) |c| switch (c.kind) { .new_drip_lot, .drip_confirmed => { any = true; drip_total += c.value(); try printChangeLine(out, c, color, pos_color); }, else => {}, }; if (!any) try printNone(out, color, mut_color); if (any) try printTotalLine(out, "Total", drip_total, color, h_color); try out.writeAll("\n"); // ── Section: Rollup share deltas (ambiguous) ── try printSection(out, "Rollup share deltas (DRIP or contribution)", color, h_color); any = false; var rollup_total: f64 = 0; for (report.changes) |c| switch (c.kind) { .rollup_delta => { any = true; rollup_total += c.value(); try printChangeLine(out, c, color, pos_color); }, else => {}, }; if (!any) try printNone(out, color, mut_color); if (any) try printTotalLine(out, "Total", rollup_total, color, h_color); try out.writeAll("\n"); // ── Section: CD events ── try printSection(out, "CD events", color, h_color); any = false; var cd_interest_total: f64 = 0; for (report.changes) |c| switch (c.kind) { .cd_matured, .cd_removed_early => { any = true; // Find same-account cash_delta to compute implied interest. var matched_cash_delta: ?f64 = null; for (report.changes) |c2| { if (c2.kind == .cash_delta and std.mem.eql(u8, c2.account, c.account)) { matched_cash_delta = c2.value(); break; } } const interest: ?f64 = if (c.kind == .cd_matured and matched_cash_delta != null) matched_cash_delta.? - c.face_value else null; if (interest) |i| if (i > 0) { cd_interest_total += i; }; try printCdLine(out, c, interest, color); }, else => {}, }; if (!any) try printNone(out, color, mut_color); if (any and cd_interest_total > 0) try printTotalLine(out, "Implied interest captured", cd_interest_total, color, h_color); try out.writeAll("\n"); // ── Section: Cash deltas ── try printSection(out, "Cash deltas (raw balance changes)", color, h_color); any = false; for (report.changes) |c| switch (c.kind) { .cash_delta => { any = true; try printCashDeltaLine(out, c, report, color); }, else => {}, }; if (!any) try printNone(out, color, mut_color); try out.writeAll("\n"); // ── Section: Transfers (matched — not counted) ── // // Any record from `transaction_log.srf` that matched a // destination-side Change (or pooled cash on the receiving // account). These entries contribute $0 to attribution; the // destination's lot/cash value is reclassified as internal // money movement, not new contribution. Shown between Cash // deltas and Lot edits so the user can cross-reference them // against the raw cash movement above. var any_xfer = false; for (report.changes) |c| switch (c.kind) { .transfer_in, .partial_transfer_in, .transfer_out => { any_xfer = true; break; }, else => {}, }; if (any_xfer) { try printSection(out, "Transfers (matched — not counted)", color, h_color); for (report.changes) |c| switch (c.kind) { .transfer_in, .partial_transfer_in, .transfer_out => { try printTransferLine(out, c, color, mut_color); }, else => {}, }; try out.writeAll("\n"); } // ── Section: Price-only updates ── var any_price = false; for (report.changes) |c| if (c.kind == .price_only) { any_price = true; break; }; if (any_price) { try printSection(out, "Price-only updates (informational)", color, h_color); for (report.changes) |c| switch (c.kind) { .price_only => { try printPriceOnlyLine(out, c, color, mut_color); }, else => {}, }; try out.writeAll("\n"); } // ── Section: Lot edits (reclassified, not counted) ── // // Broken strict lot keys that matched a secondary key with // approximately-equal share totals. Shown as a muted section so // the user can verify the reclassification was correct (e.g. a CD // auto-renewed `open_date`, an account got renamed, or the // tax-loss account had shares tweaked during reconciliation). // Not counted as contributions, DRIP, or removals. var any_edit = false; for (report.changes) |c| if (c.kind == .lot_edited) { any_edit = true; break; }; if (any_edit) { try printSection(out, "Lot edits (same position, key rewritten — not counted)", color, h_color); for (report.changes) |c| switch (c.kind) { .lot_edited => { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &buf, " {s: <12} {s: <24} (strict key broke, shares unchanged)\n", .{ c.symbol, c.account }, ) catch " (lot edit)\n"; try cli.printFg(out, color, mut_color, "{s}", .{msg}); }, else => {}, }; try out.writeAll("\n"); } // ── Section: Flagged ── var any_flag = false; for (report.changes) |c| switch (c.kind) { .flagged, .lot_removed, .drip_negative, .unmatched_transfer => any_flag = true, else => {}, }; if (any_flag) { try printSection(out, "Flagged for review", color, h_color); for (report.changes) |c| switch (c.kind) { .flagged, .lot_removed, .drip_negative => { try printFlaggedLine(out, c, color, warn_color); }, .unmatched_transfer => { try printUnmatchedTransferLine(out, c, color, warn_color); }, else => {}, }; try out.writeAll("\n"); } // ── Summary ── try printSection(out, "Summary by account", color, h_color); var ait = report.account_totals.iterator(); var total_new: f64 = 0; var total_drip: f64 = 0; var total_rollup: f64 = 0; var total_cd_int: f64 = 0; // Print per-account rows, recomputing CD interest as we go. while (ait.next()) |entry| { const acct = entry.key_ptr.*; const t = entry.value_ptr.*; // Recompute this account's CD interest from change list. var cd_int: f64 = 0; var face: f64 = 0; for (report.changes) |c| { if (!std.mem.eql(u8, c.account, acct)) continue; if (c.kind == .cd_matured) face += c.face_value; } if (face > 0 and t.cash_delta > 0) { const i = t.cash_delta - face; if (i > 0) cd_int = i; } total_new += t.new_money; total_drip += t.drip_confirmed; total_rollup += t.rollup; total_cd_int += cd_int; const acct_label = if (acct.len == 0) "(no account)" else acct; try out.print(" {s:<28}", .{acct_label}); try printSummaryCell(out, " new", t.new_money, color); try printSummaryCell(out, " drip", t.drip_confirmed, color); try printSummaryCell(out, " rollup", t.rollup, color); try printSummaryCell(out, " cd-int", cd_int, color); try out.writeAll("\n"); } try out.writeAll("\n"); // Grand totals try cli.setBold(out, color); try cli.printFg(out, color, h_color, "Totals\n", .{}); try out.print(" New contributions / purchases: {f}\n", .{Money.from(total_new)}); try out.print(" DRIP (confirmed): {f}\n", .{Money.from(total_drip)}); try out.print(" Rollup share deltas: {f} (DRIP or contribution; can't distinguish)\n", .{Money.from(total_rollup)}); if (total_cd_int > 0) { try out.print(" CD interest captured: {f}\n", .{Money.from(total_cd_int)}); } // Grand total across everything "money in"-ish. CD interest is // included because it's real return realized during the window, // even though it originated inside the portfolio. const grand = total_new + total_drip + total_rollup + total_cd_int; try cli.printFg(out, color, h_color, " Grand total: {f}\n", .{Money.from(grand)}); } fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void { try cli.setBold(out, color); try cli.printFg(out, color, hdr, "== {s} ==\n", .{title}); } fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void { try cli.printFg(out, color, muted, " (none)\n", .{}); } fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void { try cli.printFg(out, color, hdr, " {s}: {f}\n", .{ label, Money.from(v) }); } fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !void { var share_buf: [32]u8 = undefined; var price_buf: [32]u8 = undefined; var val_buf: [32]u8 = undefined; const share_str = std.fmt.bufPrint(&share_buf, "{d:.4}", .{c.delta_shares}) catch "?"; const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(c.unit_value)}) catch "$?"; const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.value())}) catch "$?"; const acct = if (c.account.len == 0) "(no account)" else c.account; try out.print(" {s:<14}{s:<24}", .{ c.symbol, acct }); if (c.security_type == .cash) { try cli.printFg(out, color, pos, " {s}", .{val_str}); } else { try out.print(" {s} shares × {s} = ", .{ share_str, price_str }); try cli.printFg(out, color, pos, "{s}", .{val_str}); } try out.writeAll("\n"); } fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bool) !void { var mat_buf: [10]u8 = undefined; const mat_str = if (c.maturity_date) |d| (std.fmt.bufPrint(&mat_buf, "{f}", .{d}) catch "????-??-??") else "(no maturity)"; const acct = if (c.account.len == 0) "(no account)" else c.account; const verb = switch (c.kind) { .cd_matured => "matured", .cd_removed_early => "removed EARLY", else => "removed", }; try out.print(" {s:<14}{s:<24} {s:<16} face {f} maturity {s}\n", .{ c.symbol, acct, verb, Money.from(c.face_value), mat_str, }); if (implied_interest) |i| { try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {f}\n", .{ "", "", Money.from(i) }); } } fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, color: bool) !void { const v = c.value(); const acct = if (c.account.len == 0) "(no account)" else c.account; const sign = if (v >= 0) "+" else "-"; try out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct }); try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) }); // Hint if a CD matured in the same account. for (report.changes) |o| { if (o.kind == .cd_matured and std.mem.eql(u8, o.account, c.account)) { try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {f})", .{Money.from(o.face_value)}); break; } } try out.writeAll("\n"); } fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { const acct = if (c.account.len == 0) "(no account)" else c.account; try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {f} → {f}\n", .{ c.symbol, acct, Money.from(c.old_price), Money.from(c.new_price), }); } fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void { const acct = if (c.account.len == 0) "(no account)" else c.account; try cli.setFg(out, color, warn); switch (c.kind) { .flagged => { try out.print(" {s:<14}{s:<24} {s}", .{ c.symbol, acct, c.detail orelse "edited" }); }, .lot_removed => { try out.print(" {s:<14}{s:<24} {s} lot removed (face {f})", .{ c.symbol, acct, @tagName(c.security_type), Money.from(c.face_value), }); }, .drip_negative => { try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({f})", .{ c.symbol, acct, Money.from(@abs(c.value())), }); }, else => {}, } try cli.reset(out, color); try out.writeAll("\n"); } fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) !void { try out.print("{s} ", .{label}); if (v == 0) { try cli.printFg(out, color, cli.CLR_MUTED, "{s:>12}", .{"-"}); } else { try cli.printFg(out, color, cli.CLR_POSITIVE, "{f}", .{Money.from(v).padRight(12)}); } } // ── Transfer-related line printers ─────────────────────────── /// Two-line rendering for a matched transfer (either a destination /// lot/cash match or a from-side match). Muted throughout — these /// don't count toward attribution so visually step them back. /// /// ``` /// 2026-05-02 $145,300.00 Acct A → Acct B (full attribution) /// → SYM@2026-05-03 /// ``` fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { var date_buf: [10]u8 = undefined; const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??"; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?"; const from_str = c.transfer_from orelse "?"; // For transfer_in / partial on the destination side, c.account // is the `to` account. For transfer_out, c.account is the `from` // account (the sending Change). Label accordingly. const arrow_from = if (c.kind == .transfer_out) c.account else from_str; const arrow_to = if (c.kind == .transfer_out) "?" // from-side match has no explicit `to` carried through else c.account; const tag: []const u8 = switch (c.kind) { .transfer_in => "(full attribution)", .partial_transfer_in => "(partial attribution)", .transfer_out => "(from side)", else => "", }; try cli.printFg( out, color, muted, " {s} {s} {s} → {s} {s}\n", .{ date_str, val_str, arrow_from, arrow_to, tag }, ); // Second line: destination detail. For lot destinations, show // the SYM@DATE. For cash, show "→ cash". For partial, show the // lot_value / attributed breakdown. if (c.kind == .partial_transfer_in) { const lot_value = c.value(); const residual = lot_value - c.transfer_attributed; try cli.printFg( out, color, muted, " → {s} ({f} of {f} lot — {f} from pre-existing cash)\n", .{ if (c.symbol.len > 0) c.symbol else "cash", Money.from(c.transfer_attributed), Money.from(lot_value), Money.from(residual), }, ); } else if (c.symbol.len > 0) { try cli.printFg(out, color, muted, " → {s}\n", .{c.symbol}); } else { try cli.printFg(out, color, muted, " → cash\n", .{}); } // Optional note from the record. if (c.transfer_note) |n| { if (n.len > 0) { try cli.printFg(out, color, muted, " ({s})\n", .{n}); } } } /// Single-line rendering for an unmatched transfer record, shown /// in the Flagged section. Keeps the layout similar to /// `printFlaggedLine` for visual consistency. fn printUnmatchedTransferLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void { var date_buf: [10]u8 = undefined; const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??"; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?"; const from_str = c.transfer_from orelse "?"; try cli.setFg(out, color, warn); try out.print(" ? Transfer {s} {s} {s} → {s}\n", .{ date_str, val_str, from_str, c.account }); if (c.transfer_note) |n| { try out.print(" {s}\n", .{n}); } try cli.reset(out, color); } /// Render a `partial_transfer_in` row in the "New contributions" /// section. Shows the residual value (the portion that wasn't /// attributable to the transfer) plus an annotation explaining the /// split so the user can tell at a glance why the number is smaller /// than the lot's face value. fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8, muted: [3]u8) !void { const acct = if (c.account.len == 0) "(no account)" else c.account; const residual = c.attributedValue(); const lot_value = c.value(); const sym = if (c.symbol.len > 0) c.symbol else "cash"; try out.print(" {s:<14}{s:<24}", .{ sym, acct }); try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)}); try cli.printFg( out, color, muted, " (of {f} total — rest from transfer)\n", .{Money.from(lot_value)}, ); } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs { var ctx: framework.RunCtx = .{ .io = std.testing.io, .allocator = std.testing.allocator, .gpa = std.testing.allocator, // SAFETY: parseArgs doesn't touch environ_map. .environ_map = undefined, .config = .{ .cache_dir = "" }, .svc = null, .globals = .{}, .today = today, .now_s = 0, .color = false, // SAFETY: parseArgs doesn't write to out. .out = undefined, }; return parseArgs(&ctx, args); } test "parseArgs: empty args produces both null" { const today = zfin.Date.fromYmd(2026, 5, 9); const parsed = try parseArgsForTest(today, &.{}); try testing.expect(parsed.before == null); try testing.expect(parsed.after == null); } test "parseArgs: --since populates before as date_at_or_before" { const today = zfin.Date.fromYmd(2026, 5, 9); const args = [_][]const u8{ "--since", "2026-04-01" }; const parsed = try parseArgsForTest(today, &args); switch (parsed.before.?) { .date_at_or_before => |d| try testing.expect(d.eql(zfin.Date.fromYmd(2026, 4, 1))), else => try testing.expect(false), } } test "parseArgs: --since + --until populates both" { const today = zfin.Date.fromYmd(2026, 5, 9); const args = [_][]const u8{ "--since", "2026-04-01", "--until", "2026-05-01" }; const parsed = try parseArgsForTest(today, &args); try testing.expect(parsed.before != null); try testing.expect(parsed.after != null); } test "parseArgs: --commit-after working" { const today = zfin.Date.fromYmd(2026, 5, 9); const args = [_][]const u8{ "--commit-after", "working" }; const parsed = try parseArgsForTest(today, &args); try testing.expect(parsed.after.? == .working_copy); } test "parseArgs: --since + --commit-before is duplicate axis" { const today = zfin.Date.fromYmd(2026, 5, 9); const args = [_][]const u8{ "--since", "1W", "--commit-before", "HEAD" }; try testing.expectError(error.DuplicateEndpoint, parseArgsForTest(today, &args)); } test "parseArgs: --since after --until errors" { const today = zfin.Date.fromYmd(2026, 5, 9); const args = [_][]const u8{ "--since", "2026-05-01", "--until", "2026-04-01" }; try testing.expectError(error.InvalidArg, parseArgsForTest(today, &args)); } test "parseArgs: unknown flag errors" { const today = zfin.Date.fromYmd(2026, 5, 9); const args = [_][]const u8{ "--bogus", "value" }; try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args)); } test "computeReport: fresh stock purchase counts as new contribution" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AAPL", 200.0); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 180, .account = "Brokerage" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 1800.0), report.changes[0].value(), 0.01); } test "computeReport: rollup_delta when shares increase on untagged lot" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("VBTLX", 9.79); const before = [_]Lot{ .{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" }, }; const after = [_]Lot{ .{ .symbol = "VBTLX", .shares = 1010, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 9.79, .account = "DCP" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); // drip::false on both sides → rollup_delta (ambiguous: DRIP or contribution) try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 10.0), report.changes[0].delta_shares, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 97.9), report.changes[0].value(), 0.01); } test "computeReport: matured CD with maturity_date <= today" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{ .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Emil IRA", .maturity_date = Date.fromYmd(2026, 4, 17) }, }; const after = [_]Lot{}; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.cd_matured, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 58000.0), report.changes[0].face_value, 0.01); } test "computeReport: CD removed before maturity flagged as early" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{ .{ .symbol = "CD2", .shares = 50000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Brokerage", .maturity_date = Date.fromYmd(2027, 1, 1) }, }; const after = [_]Lot{}; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.cd_removed_early, report.changes[0].kind); } test "computeReport: price-only update not classified as cash flow" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{ .{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9), .account = "401k" }, }; const after = [_]Lot{ .{ .symbol = "NON40OR52", .shares = 5000, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18), .account = "401k" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.price_only, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 161.71), report.changes[0].old_price, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 169.07), report.changes[0].new_price, 0.01); } test "computeReport: CD matured + cash increase -> implied interest available" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); // Before: CD $58k + cash $3k. After: no CD, cash $62.5k. const before = [_]Lot{ .{ .symbol = "CDX", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 4, 17) }, .{ .symbol = "CASH", .shares = 3024.66, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" }, }; const after = [_]Lot{ .{ .symbol = "CASH", .shares = 62510.95, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cash, .account = "IRA" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); // Expect: 1 cd_matured, 1 cash_delta var n_matured: usize = 0; var n_cash: usize = 0; var cash_delta: f64 = 0; var face: f64 = 0; for (report.changes) |c| switch (c.kind) { .cd_matured => { n_matured += 1; face += c.face_value; }, .cash_delta => { n_cash += 1; cash_delta += c.value(); }, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_matured); try std.testing.expectEqual(@as(usize, 1), n_cash); try std.testing.expectApproxEqAbs(@as(f64, 58000.0), face, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 62510.95 - 3024.66), cash_delta, 0.01); // Implied interest: cash_delta - face = 59486.29 - 58000 = 1486.29 try std.testing.expectApproxEqAbs(@as(f64, 1486.29), cash_delta - face, 0.01); } test "computeReport: unit_value prefers current price over open_price for rollup delta" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 461.24); const before = [_]Lot{ .{ .symbol = "SPY", .shares = 717.34, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" }, }; const after = [_]Lot{ .{ .symbol = "SPY", .shares = 718.4848, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.24, .account = "Tax Loss" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind); // 1.1448 * 461.24 ≈ 528.07 try std.testing.expectApproxEqAbs(@as(f64, 528.07), report.changes[0].value(), 0.1); } test "computeReport: manual-priced lot (price:: no ticker) uses manual price" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); // No price in the map — should fall back to manual price::. const before = [_]Lot{ .{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" }, }; const after = [_]Lot{ .{ .symbol = "NON40OR52", .shares = 5075.077, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .price = 169.07, .account = "401k" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.rollup_delta, report.changes[0].kind); // (5075.077 - 5070.866) = 4.211 shares × 169.07 = 711.96 try std.testing.expectApproxEqAbs(@as(f64, 711.96), report.changes[0].value(), 0.5); } test "computeReport: maturity_date change on same CD is flagged" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{ .{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2026, 7, 15) }, }; const after = [_]Lot{ .{ .symbol = "CDY", .shares = 87000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 7, 15) }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.flagged, report.changes[0].kind); } test "computeReport: CD open_date rewrite reclassified as edit, not new+removed" { // The classic CD auto-renewal scenario: shares and account stay // the same, but the `open_date` gets rewritten to the renewal // date. Prior behavior was a phantom $58k new_cd contribution // plus a $58k cd_removed_early. After edit detection: one // lot_edited, no contribution. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{ .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 2, 25) }, }; const after = [_]Lot{ // Same CD, rewritten open_date (e.g. renewal) — key broken. .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 4, 20), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = Date.fromYmd(2027, 4, 20) }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind); // Attribution must see zero contribution for this CD. var new_contrib: f64 = 0; var drip: f64 = 0; for (report.changes) |c| switch (c.kind) { .new_stock, .new_cash, .new_cd, .new_option => new_contrib += c.value(), .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(), else => {}, }; try std.testing.expectApproxEqAbs(@as(f64, 0), new_contrib, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0), drip, 0.01); } test "computeReport: stock open_price renormalized reclassified as edit" { // Reconciliation tweak: user updates `open_price` to match the // institutional-share-class NAV, leaving everything else alone. // Prior behavior: phantom new_stock for the full lot value. // After edit detection: lot_edited. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("TAXLOSS", 50.0); const before = [_]Lot{ .{ .symbol = "TAXLOSS", .shares = 100, .open_date = Date.fromYmd(2026, 1, 15), .open_price = 45.0, .account = "Tax Loss" }, }; const after = [_]Lot{ .{ .symbol = "TAXLOSS", .shares = 100, .open_date = Date.fromYmd(2026, 1, 15), .open_price = 48.0, .account = "Tax Loss" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind); } test "computeReport: symbol rename with ticker alias collapses to lot_edited" { // 2026-05-02 regression: user changed a tax-loss lot from // `symbol::SPY` to `symbol::DI-SPX, ticker::SPY` (direct-indexing // proxy) and tweaked shares by ~1% during reconciliation. Same // underlying SPY exposure, same account, same open_date / // open_price — should collapse to an edit, NOT a ~$327k phantom // contribution. // // Before the `priceSymbol()`-based secondary key, the raw-symbol // comparison treated "SPY" and "DI-SPX" as different positions // and emitted a `new_stock` + `lot_removed` pair worth the full // lot value (~715 × $461 = $330k). // // After the fix: one `lot_edited` for the identity continuity, // plus a `rollup_delta` for the ~6-share residual worth ~$3k. // The attribution total sees only the residual, not the full lot. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 461.24); const before = [_]Lot{ .{ .symbol = "SPY", .shares = 715.912037, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", }, }; const after = [_]Lot{ .{ .symbol = "DI-SPX", .ticker = "SPY", .shares = 709.235272, // ~1% tweak during reconciliation .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", .price_ratio = 1.0, }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); // Expect one lot_edited + one residual share-delta change // (rollup_delta is used here because delta < 0 would be // drip_negative — but 709 < 715 means after < before, so // drip_negative). Actually delta = 709 - 715 = -6, so // drip_negative. var n_edit: usize = 0; var n_rollup: usize = 0; var n_drip_neg: usize = 0; var n_new_stock: usize = 0; var n_removed: usize = 0; var residual_value: f64 = 0; for (report.changes) |c| switch (c.kind) { .lot_edited => n_edit += 1, .rollup_delta => { n_rollup += 1; residual_value = c.value(); }, .drip_negative => { n_drip_neg += 1; residual_value = c.value(); }, .new_stock => n_new_stock += 1, .lot_removed => n_removed += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_edit); try std.testing.expectEqual(@as(usize, 0), n_new_stock); try std.testing.expectEqual(@as(usize, 0), n_removed); // Share count went DOWN, so we get a drip_negative for the // residual (not rollup_delta). try std.testing.expectEqual(@as(usize, 1), n_drip_neg); try std.testing.expectEqual(@as(usize, 0), n_rollup); // ~6.68 shares × 461.24 ≈ $3,080 — the real reconciliation-scale // movement, not the ~$330k phantom. try std.testing.expect(residual_value < 0); // drip_negative sign try std.testing.expect(@abs(residual_value) < 5_000); // nowhere near $330k } test "computeReport: direct_indexing account suppresses sub-1% residual" { // Tax-loss direct-indexing proxy: same symbol/account/key but // small share drift (0.5%) from tracking-error reconciliation. // With no account_map, the default 0.01% tolerance surfaces this // as a rollup_delta. With account_map flagging the account as // direct_indexing, the looser 1% tolerance swallows it — only // the lot_edited marker is emitted, no residual that would land // in the attribution total. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 461.24); const before = [_]Lot{ .{ .symbol = "SPY", .shares = 1000.0, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", }, }; const after = [_]Lot{ // Broken strict key (symbol+ticker alias rewrite) plus a // 0.5% share decrease from tracking-error reconciliation. .{ .symbol = "DI-SPX", .ticker = "SPY", .shares = 995.0, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", .price_ratio = 1.0, }, }; // Build an account_map flagging Tax Loss as direct-indexing. var am_entries = [_]analysis.AccountTaxEntry{ .{ .account = "Tax Loss", .tax_type = .taxable, .direct_indexing = true, }, }; const account_map = analysis.AccountMap{ .entries = &am_entries, .allocator = allocator, }; const report = try computeReport( allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{ .account_map = &account_map }, ); var n_edit: usize = 0; var n_rollup_or_drip: usize = 0; for (report.changes) |c| switch (c.kind) { .lot_edited => n_edit += 1, .rollup_delta, .drip_negative, .drip_confirmed => n_rollup_or_drip += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_edit); // Looser tolerance swallows the 0.5% residual — no rollup/drip // leak into the attribution total. try std.testing.expectEqual(@as(usize, 0), n_rollup_or_drip); } test "computeReport: direct_indexing same-key drift suppressed in Pass 1" { // Tax-loss direct-indexing proxy with SAME strict key on both // sides (no rename this week) but small share drift from // tracking-error reconciliation. Pass 1 would normally emit a // drip_negative; with direct_indexing the 1% tolerance suppresses // it the same way detectEdits does. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 461.24); const before = [_]Lot{ .{ .symbol = "DI-SPX", .ticker = "SPY", .shares = 1000.0, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", }, }; const after = [_]Lot{ // Same strict key, 0.5% share decrease from tracking-error // reconciliation. .{ .symbol = "DI-SPX", .ticker = "SPY", .shares = 995.0, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", }, }; var am_entries = [_]analysis.AccountTaxEntry{ .{ .account = "Tax Loss", .tax_type = .taxable, .direct_indexing = true, }, }; const account_map = analysis.AccountMap{ .entries = &am_entries, .allocator = allocator, }; const report = try computeReport( allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{ .account_map = &account_map }, ); // No rollup/drip — the share drift was tracking error and the // direct_indexing flag suppressed it. try std.testing.expectEqual(@as(usize, 0), report.changes.len); } test "computeReport: same-key drift without direct_indexing still surfaces (sanity)" { // Sanity check that the flag is what's doing the suppression: // same setup as above but without the account_map, so Pass 1 // emits a drip_negative for the 0.5% drift. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 461.24); const before = [_]Lot{ .{ .symbol = "DI-SPX", .ticker = "SPY", .shares = 1000.0, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", }, }; const after = [_]Lot{ .{ .symbol = "DI-SPX", .ticker = "SPY", .shares = 995.0, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", }, }; const report = try computeReport( allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}, ); var n_drip_neg: usize = 0; for (report.changes) |c| switch (c.kind) { .drip_negative => n_drip_neg += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_drip_neg); } test "computeReport: direct_indexing tolerance still surfaces real contributions" { // Regression: the 1% tolerance should still catch a real // contribution. If the user transfers $100k into a multi-million // direct-indexing account (~1.2% of value), the residual should // surface as a rollup_delta so the attribution isn't silent about // real money flow. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 461.24); // 17,361 shares × $461 ≈ $8M (same scale as the user's real tax // loss account). A $100k contribution = ~217 shares = ~1.25%. const before = [_]Lot{ .{ .symbol = "DI-SPX", .ticker = "SPY", .shares = 17361.0, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 461.240208, .account = "Tax Loss", .price_ratio = 1.0, }, }; const after = [_]Lot{ // Strict-key break (different open_date — e.g. user // redated after a large contribution) AND a 1.25% share // increase. Tolerance at 1% fails the "swallow" check so // the residual surfaces. .{ .symbol = "DI-SPX", .ticker = "SPY", .shares = 17578.0, // +217 shares, ≈ +1.25% .open_date = Date.fromYmd(2026, 5, 2), .open_price = 461.240208, .account = "Tax Loss", .price_ratio = 1.0, }, }; var am_entries = [_]analysis.AccountTaxEntry{ .{ .account = "Tax Loss", .tax_type = .taxable, .direct_indexing = true, }, }; const account_map = analysis.AccountMap{ .entries = &am_entries, .allocator = allocator, }; const report = try computeReport( allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 2), .{ .account_map = &account_map }, ); var n_edit: usize = 0; var n_rollup: usize = 0; for (report.changes) |c| switch (c.kind) { .lot_edited => n_edit += 1, .rollup_delta => n_rollup += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_edit); // 1.25% is over the 1% tolerance, so we get a rollup_delta. try std.testing.expectEqual(@as(usize, 1), n_rollup); } test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also collapses" { // Reverse direction: before has a CUSIP-style symbol with ticker // alias, after has the plain ticker with no alias. Both resolve // to the same `priceSymbol()` → edit, not new+removed. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("VTTHX", 27.78); const before = [_]Lot{ .{ .symbol = "00766V100", // fake CUSIP-style string .ticker = "VTTHX", .shares = 5000, .open_date = Date.fromYmd(2023, 1, 15), .open_price = 25.0, .account = "401k", }, }; const after = [_]Lot{ .{ .symbol = "VTTHX", .shares = 5000, .open_date = Date.fromYmd(2023, 1, 15), .open_price = 25.0, .account = "401k", }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind); } test "computeReport: different tickers stay distinct (no false collapse)" { // Sanity: VOO → VTI in the same account with a broken strict key // is NOT the same underlying position. Must not collapse into an // edit. Should classify as new + removed. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("VOO", 450.0); try prices.put("VTI", 230.0); const before = [_]Lot{ .{ .symbol = "VOO", .shares = 100, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 400.0, .account = "IRA", }, }; const after = [_]Lot{ .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 200.0, .account = "IRA", }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); var n_edit: usize = 0; var n_new: usize = 0; var n_removed: usize = 0; for (report.changes) |c| switch (c.kind) { .lot_edited => n_edit += 1, .new_stock => n_new += 1, .lot_removed => n_removed += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 0), n_edit); try std.testing.expectEqual(@as(usize, 1), n_new); try std.testing.expectEqual(@as(usize, 1), n_removed); } test "computeReport: account rename is NOT collapsed (documented limitation)" { // Renaming an account string in portfolio.srf (e.g. "Brokerage" → // "Joint Brokerage") breaks the secondary key too, since that key // includes the account. Edit detection DOES NOT cover this case // — the rename looks indistinguishable from a transfer (closing // one account, opening another with the same positions). Account // renames must therefore be handled manually: either avoid them // during a review window, or accept the phantom attribution for // that week. Tracked in TODO.md. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AAPL", 180.0); try prices.put("MSFT", 400.0); const before = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Brokerage" }, .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 300.0, .account = "Brokerage" }, }; const after = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Joint Brokerage" }, .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 300.0, .account = "Joint Brokerage" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); // Expectation: account rename collapses into regular new+removed // classification (NOT lot_edited). If this ever flips to 2/0/0, // edit detection has gained account-rename awareness — great, // but update this test and the TODO accordingly. var n_edit: usize = 0; var n_new: usize = 0; var n_removed: usize = 0; for (report.changes) |c| switch (c.kind) { .lot_edited => n_edit += 1, .new_stock => n_new += 1, .lot_removed => n_removed += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 0), n_edit); try std.testing.expectEqual(@as(usize, 2), n_new); try std.testing.expectEqual(@as(usize, 2), n_removed); } test "computeReport: big share delta with broken key emits lot_edited + residual rollup" { // Secondary key matches (same security_type + priceSymbol + // account) but the share total diverges significantly. This is // the "user broke the lot key AND had a real contribution in // the same window" case. The lot identity still collapses to an // edit, but the share delta surfaces as a rollup_delta so the // attribution total sees the real inflow — not the full lot // value as a phantom contribution. // // Prior behavior (1% share tolerance): fell through to // new_stock + lot_removed, over-counting by the full lot value. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AAPL", 180.0); const before = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 150.0, .account = "Brokerage" }, }; const after = [_]Lot{ // Same symbol/account but BIG share delta and different // open_date. The 400-share delta is real new money; the // 100-share continuation is an edit. .{ .symbol = "AAPL", .shares = 500, .open_date = Date.fromYmd(2026, 4, 15), .open_price = 175.0, .account = "Brokerage" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); var n_edit: usize = 0; var n_rollup: usize = 0; var n_new: usize = 0; var n_removed: usize = 0; var rollup_value: f64 = 0; for (report.changes) |c| switch (c.kind) { .lot_edited => n_edit += 1, .rollup_delta => { n_rollup += 1; rollup_value += c.value(); }, .new_stock => n_new += 1, .lot_removed => n_removed += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_edit); try std.testing.expectEqual(@as(usize, 1), n_rollup); try std.testing.expectEqual(@as(usize, 0), n_new); try std.testing.expectEqual(@as(usize, 0), n_removed); // 400-share delta × $180 current price = $72k try std.testing.expectApproxEqAbs(@as(f64, 72_000.0), rollup_value, 1.0); } test "computeReport: tiny share drift emits lot_edited + residual rollup" { // Fractional DRIP share-count, e.g. 10.0 → 10.05 with a // reconciliation tweak and a key rewrite. Under the always-collapse // design, this produces lot_edited + a small rollup_delta for // the 0.05-share residual. The residual survives the 0.01% noise // tolerance because 0.5% > 0.01%. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 500.0); const before = [_]Lot{ .{ .symbol = "SPY", .shares = 10.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 420.0, .account = "IRA" }, }; const after = [_]Lot{ .{ .symbol = "SPY", .shares = 10.05, .open_date = Date.fromYmd(2023, 8, 1), .open_price = 430.0, .account = "IRA" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); var n_edit: usize = 0; var n_rollup: usize = 0; for (report.changes) |c| switch (c.kind) { .lot_edited => n_edit += 1, .rollup_delta => n_rollup += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_edit); try std.testing.expectEqual(@as(usize, 1), n_rollup); } test "computeReport: sub-noise share drift emits only lot_edited" { // If the share delta is below the residual-tolerance threshold // (0.01%), we emit only lot_edited — no spurious rollup_delta // from floating-point noise. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SPY", 500.0); const before = [_]Lot{ .{ .symbol = "SPY", .shares = 1000.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 420.0, .account = "IRA" }, }; const after = [_]Lot{ // 0.00005% drift (well under 0.01% tolerance) .{ .symbol = "SPY", .shares = 1000.0000005, .open_date = Date.fromYmd(2023, 8, 1), .open_price = 430.0, .account = "IRA" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 21), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.lot_edited, report.changes[0].kind); } test "computeReport: new lot with drip::true classified as new_drip_lot" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "FAGIX", .shares = 10.0, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 11.00, .account = "Kelly IRA", .drip = true }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.new_drip_lot, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 110.0), report.changes[0].value(), 0.01); } test "computeReport: new stock lot without drip flag is new_stock" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2026, 4, 10), .open_price = 180, .account = "Brokerage" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind); } test "computeReport: drip::true existing lot with shares increase is drip_confirmed" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("FAGIX", 11.00); const before = [_]Lot{ .{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Kelly IRA", .drip = true }, }; const after = [_]Lot{ .{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Kelly IRA", .drip = true }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.drip_confirmed, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 55.0), report.changes[0].value(), 0.01); } test "computeReport: per-account totals separate drip_confirmed from rollup" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("FAGIX", 11.00); try prices.put("VBTLX", 9.79); const before = [_]Lot{ .{ .symbol = "FAGIX", .shares = 100, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true }, .{ .symbol = "VBTLX", .shares = 1000, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" }, }; const after = [_]Lot{ .{ .symbol = "FAGIX", .shares = 110, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "AcctA", .drip = true }, .{ .symbol = "VBTLX", .shares = 1020, .open_date = Date.fromYmd(2026, 2, 1), .open_price = 9.79, .account = "AcctA" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 4, 18), .{}); const t = report.account_totals.get("AcctA") orelse { try std.testing.expect(false); return; }; // drip_confirmed: 10 * 11 = 110 try std.testing.expectApproxEqAbs(@as(f64, 110.0), t.drip_confirmed, 0.01); // rollup: 20 * 9.79 = 195.8 try std.testing.expectApproxEqAbs(@as(f64, 195.8), t.rollup, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0), t.new_money, 0.01); } // ── resolveEndpoints tests ─────────────────────────────────── // // Only the legacy (no-flags) and --since-only branches that don't // shell out to git can be unit-tested cheaply. The full flag paths // (`--since`, `--since`+`--until`) depend on `git log --until=`, // which requires a real repo and is covered by `src/git.zig` tests // plus manual smoke-testing. test "resolveEndpoints: legacy dirty → HEAD vs working copy" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" }; const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), repo, null, null, true, .verbose); try std.testing.expectEqualStrings("HEAD", eps.range.before_rev); try std.testing.expect(eps.range.after_rev == null); try std.testing.expect(std.mem.indexOf(u8, eps.label, "working copy against HEAD") != null); } test "resolveEndpoints: legacy clean → HEAD~1 vs HEAD" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const repo: git.RepoInfo = .{ .root = "/tmp", .rel_path = "portfolio.srf" }; const eps = try resolveEndpoints(std.testing.io, arena_state.allocator(), repo, null, null, false, .verbose); try std.testing.expectEqualStrings("HEAD~1", eps.range.before_rev); try std.testing.expectEqualStrings("HEAD", eps.range.after_rev.?); try std.testing.expect(std.mem.indexOf(u8, eps.label, "HEAD~1 against HEAD") != null); } test "short: long SHA truncates to 7 chars" { // Works for both SHA-1 (40) and SHA-256 (64). Use a 40-char // input as the common case; the function only cares that input // is >= 7 chars. const sha = "0123456789abcdef0123456789abcdef01234567"; try std.testing.expectEqualStrings("0123456", short(sha)); } test "short: SHA-256 length also truncates to 7" { // Forward-compat: same behavior regardless of hash algorithm. const sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; try std.testing.expectEqualStrings("0123456", short(sha)); } test "short: short input returned as-is" { try std.testing.expectEqualStrings("abc", short("abc")); } // ── matchTransfers tests ───────────────────────────────────── // // These exercise the transfer reclassification pipeline end-to-end // via `computeReport(... .{ .transfer_log = &log, ... })`. Each test // parses a small SRF fragment into a `TransactionLog`, wires it into // `ReportOptions`, and asserts the resulting `Report.changes` kinds // and per-account attribution. test "diffTransferLogs: empty before, all records new" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const after = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash \\ ); const new_records = try diffTransferLogs(arena, null, &after); try std.testing.expectEqual(@as(usize, 2), new_records.len); } test "diffTransferLogs: identical before and after returns empty" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const before = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const after = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const new_records = try diffTransferLogs(arena, &before, &after); try std.testing.expectEqual(@as(usize, 0), new_records.len); } test "diffTransferLogs: only the new record returned" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const before = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const after = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash \\ ); const new_records = try diffTransferLogs(arena, &before, &after); try std.testing.expectEqual(@as(usize, 1), new_records.len); try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, new_records[0].transfer.days); } test "diffTransferLogs: edited record treated as new" { // User changed the `from` field on a previously-recorded transfer. // Old form is in `before`, new form is in `after`. The new form // doesn't equal anything in before → returned as new. The old // form is in before but not after → silently dropped (its diff // cycle is over). var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const before = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Wrong Acct,to::Acct B,dest_lot::cash \\ ); const after = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const new_records = try diffTransferLogs(arena, &before, &after); try std.testing.expectEqual(@as(usize, 1), new_records.len); try std.testing.expectEqualStrings("Acct A", new_records[0].from); } test "diffTransferLogs: out-of-order records still match correctly" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const before = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash \\ ); // After has the same two records but in reverse order. const after = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-03,type::cash,amount:num:1000,from::Acct C,to::Acct D,dest_lot::cash \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const new_records = try diffTransferLogs(arena, &before, &after); try std.testing.expectEqual(@as(usize, 0), new_records.len); } test "diffTransferLogs: back-dated record added on top of existing log" { // Regression test for the original bug: user records a transfer // months after it actually happened. The before-side log has // some prior records; the after-side adds one with an old // transfer::DATE. That new record must be returned even though // its date predates everything in before. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const before = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-15,type::cash,amount:num:1000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const after = try transaction_log.parseTransactionLogFile(arena, \\#!srfv1 \\transfer::2026-05-15,type::cash,amount:num:1000,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-01-15,type::cash,amount:num:9999,from::Old Acct,to::New Acct,dest_lot::cash \\ ); const new_records = try diffTransferLogs(arena, &before, &after); try std.testing.expectEqual(@as(usize, 1), new_records.len); try std.testing.expectEqual(@as(f64, 9999), new_records[0].amount); } test "matchTransfers: cash-to-cash happy path" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); // One cash lot appearing on Acct B (simulates a $5k cash top-up). const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "cash", .shares = 5000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 3), .{ .transfer_log = tlog.transfers, }); // The new_cash Change stays as new_cash in the display (we don't // flip cash-side Changes because a single cash_delta can be // drained by multiple records). A synthetic transfer_in Change // is appended for Transfers-section display, and // `cash_attributed_by_account` carries the $5k subtraction for // attribution math. var n_new_cash: usize = 0; var n_transfer_in: usize = 0; for (report.changes) |c| switch (c.kind) { .new_cash => n_new_cash += 1, .transfer_in => n_transfer_in += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_new_cash); try std.testing.expectEqual(@as(usize, 1), n_transfer_in); // Attribution on Acct B: $5k new_cash minus $5k attribution bucket = $0. const t = report.account_totals.get("Acct B").?; try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01); } test "matchTransfers: lot destination happy path" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); // Brand new $8k stock lot on Acct B. const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 8000.0), report.changes[0].transfer_attributed, 0.01); // Acct B totals: $0 new_money (transfer_in contributes 0). const t = report.account_totals.get("Acct B").?; try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01); } test "matchTransfers: partial attribution — transfer smaller than lot" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); // New $8k lot, but only $7k came from the transfer. const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind); try std.testing.expectApproxEqAbs(@as(f64, 7000.0), report.changes[0].transfer_attributed, 0.01); // Residual = $8k − $7k = $1k, contributed to Acct B new_money. try std.testing.expectApproxEqAbs(@as(f64, 1000.0), report.changes[0].attributedValue(), 0.01); const t = report.account_totals.get("Acct B").?; try std.testing.expectApproxEqAbs(@as(f64, 1000.0), t.new_money, 0.01); } test "matchTransfers: sweep — lot destination + cash residual, both match" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 1.0); // $145,300 stock lot + $4,700 cash residual on Acct B. const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 145300, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 1.0, .account = "Acct B" }, .{ .symbol = "cash", .shares = 4700, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); // Acct B new_money: $0 (both transfers fully attributed). const t = report.account_totals.get("Acct B").?; try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01); // One transfer_in on the stock lot (kind flipped from new_stock). var n_stock_transfer_in: usize = 0; var n_cash_transfer_in: usize = 0; for (report.changes) |c| switch (c.kind) { .transfer_in => { if (c.security_type == .stock) n_stock_transfer_in += 1; if (c.security_type == .cash) n_cash_transfer_in += 1; }, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_stock_transfer_in); // One synthetic cash transfer_in from the cash-dest matcher. try std.testing.expectEqual(@as(usize, 1), n_cash_transfer_in); } test "matchTransfers: duplicate dest_lot emits unmatched" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; // Two records pointing at the same (account, symbol). First matches; // second emits unmatched_transfer with "already claimed". const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); var n_transfer_in: usize = 0; var n_unmatched: usize = 0; for (report.changes) |c| switch (c.kind) { .transfer_in => n_transfer_in += 1, .unmatched_transfer => n_unmatched += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_transfer_in); try std.testing.expectEqual(@as(usize, 1), n_unmatched); } test "matchTransfers: missing dest_lot emits unmatched" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); // No new lots in the diff at all. const before = [_]Lot{}; const after = [_]Lot{}; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.unmatched_transfer, report.changes[0].kind); } test "matchTransfers: amount exceeds lot value emits unmatched" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; // $10k transfer against $8k lot. const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); // Lot stays as new_stock; unmatched record appended. var n_new_stock: usize = 0; var n_unmatched: usize = 0; for (report.changes) |c| switch (c.kind) { .new_stock => n_new_stock += 1, .unmatched_transfer => n_unmatched += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_new_stock); try std.testing.expectEqual(@as(usize, 1), n_unmatched); } test "matchTransfers: cash insufficient emits unmatched" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); // Only $3k cash showed up on Acct B, but the record wants $5k. const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "cash", .shares = 3000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); var n_unmatched: usize = 0; for (report.changes) |c| if (c.kind == .unmatched_transfer) { n_unmatched += 1; }; try std.testing.expectEqual(@as(usize, 1), n_unmatched); // new_cash stays unchanged; $3k still counts as new_money. const t = report.account_totals.get("Acct B").?; try std.testing.expectApproxEqAbs(@as(f64, 3000.0), t.new_money, 0.01); } test "matchTransfers: same-day multi-cash records drain a single cash_delta" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); // $5k cash showed up on Acct B. const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "cash", .shares = 5000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" }, }; // Two records ($2k + $3k = $5k). const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:2000,from::Acct A,to::Acct B,dest_lot::cash \\transfer::2026-05-02,type::cash,amount:num:3000,from::Acct A,to::Acct B,dest_lot::cash \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); // Both records should match (budget fully consumed); no unmatched. var n_unmatched: usize = 0; var n_transfer_in: usize = 0; for (report.changes) |c| switch (c.kind) { .unmatched_transfer => n_unmatched += 1, .transfer_in => n_transfer_in += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 0), n_unmatched); try std.testing.expectEqual(@as(usize, 2), n_transfer_in); // Acct B new_money: $5k new_cash − $5k attribution = $0. const t = report.account_totals.get("Acct B").?; try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01); } test "matchTransfers: type::in_kind always emits unmatched" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); // Even if a matching lot exists, in_kind is rejected in v1. const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); // new_stock stays as new_stock (not flipped); unmatched appended. var n_new_stock: usize = 0; var n_unmatched: usize = 0; for (report.changes) |c| switch (c.kind) { .new_stock => n_new_stock += 1, .unmatched_transfer => n_unmatched += 1, else => {}, }; try std.testing.expectEqual(@as(usize, 1), n_new_stock); try std.testing.expectEqual(@as(usize, 1), n_unmatched); } test "matchTransfers: back-dated record matches regardless of date" { // The matcher itself is date-agnostic now — the caller (typically // `prepareReport` via `diffTransferLogs`) is responsible for // narrowing the slice to records that should be considered for // this diff cycle. A record dated weeks before any "diff window" // pairs cleanly when passed to the matcher directly. // // This is the regression test for the back-dated-record-rejected // bug: user adds `transfer::2026-01-15` to transaction_log.srf // on 2026-05-04 to record a transfer that actually happened // months ago. The matcher must pair it. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-01-15,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); // Record paired despite the months-earlier date. try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind); } test "matchTransfers: null transfer_log is a no-op (backward compat)" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; // No transfer_log passed — baseline behavior with no reclassification. const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); try std.testing.expectEqual(ChangeKind.new_stock, report.changes[0].kind); } test "matchTransfers: attribution excludes transferred amount" { // End-to-end: run through the same computeReport path that // `summarizeAttribution` consumes, and verify the attribution // line would be $0 for a fully-transferred lot. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); // Replicate summarizeAttribution's logic directly. var new_contributions: f64 = 0; var drip: f64 = 0; for (report.changes) |c| switch (c.kind) { .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.value(), .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(), .partial_transfer_in => new_contributions += c.attributedValue(), else => {}, }; var cait = report.cash_attributed_by_account.iterator(); while (cait.next()) |entry| new_contributions -= entry.value_ptr.*; try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.0), drip, 0.01); } // ── collectUnmatchedLargeLots tests ────────────────────────── // // These exercise the audit large-lot filter by building a `Report` // via `computeReport` (with or without a transfer log) and feeding // its changes directly to `collectUnmatchedLargeLots`. That skips // the git + IO plumbing of `findUnmatchedLargeLots` while still // covering the classification path the production code uses. test "collectUnmatchedLargeLots: below threshold is silent" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 100.0); const before = [_]Lot{}; // $5k lot — under the $10k threshold used by audit. const after = [_]Lot{ .{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct A" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 0), lots.len); } test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 500.0); const before = [_]Lot{}; // $50k stock lot, no transfer log, should surface. const after = [_]Lot{ .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 1), lots.len); try std.testing.expectEqualStrings("Acct A", lots[0].account); try std.testing.expectEqualStrings("SYM", lots[0].symbol); try std.testing.expectEqual(LotType.stock, lots[0].security_type); try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01); try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days); } test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "cash", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 1.0, .security_type = .cash, .account = "Acct A" }, }; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{}); const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 1), lots.len); try std.testing.expectEqual(LotType.cash, lots[0].security_type); try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01); } test "collectUnmatchedLargeLots: matched via transfer log is silent" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 500.0); const before = [_]Lot{}; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" }, }; // Transfer log fully covers the lot → kind flips to transfer_in. const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); // Sanity: the lot should have been reclassified. try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind); const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 0), lots.len); } test "collectUnmatchedLargeLots: cash-destination matched is silent" { // Regression for the user-visible bug: a $73,158.33 cash lot on // Joint trust funded by a transfer record dated 2026-05-20 was // surfacing in audit's "Large new lots — confirm source" because // the cash matcher doesn't flip the original `new_cash` Change's // kind (it draws from `cash_attributed_by_account` instead). // Without subtracting that attribution, the audit filter // re-flagged a lot that's already explained. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{}; const after = [_]Lot{ .{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint trust" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Fidelity Emil,to::Joint trust,dest_lot::cash \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{ .transfer_log = tlog.transfers, }); // Cash-dest matching does NOT flip the original new_cash Change // (a single delta can be drained by multiple records). The // matcher records the attributed amount in // `cash_attributed_by_account` instead. var saw_new_cash = false; var saw_synthetic_transfer = false; for (report.changes) |c| switch (c.kind) { .new_cash => saw_new_cash = true, .transfer_in => saw_synthetic_transfer = true, else => {}, }; try std.testing.expect(saw_new_cash); try std.testing.expect(saw_synthetic_transfer); const attributed = report.cash_attributed_by_account.get("Joint trust") orelse 0; try std.testing.expectEqual(@as(f64, 73158.33), attributed); // The audit filter must subtract the attribution and stay quiet. const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 0), lots.len); } test "collectUnmatchedLargeLots: cash-destination partial match surfaces residual only" { // A $50K cash lot with a $30K transfer attributed against it — // the residual $20K is "new contribution" and SHOULD surface // (above the $10K threshold). The filter reports the residual, // not the gross. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{}; const after = [_]Lot{ .{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint trust" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-20,type::cash,amount:num:30000,from::Fidelity Emil,to::Joint trust,dest_lot::cash \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{ .transfer_log = tlog.transfers, }); const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 1), lots.len); try std.testing.expectEqual(@as(f64, 20000.0), lots[0].value); } test "collectUnmatchedLargeLots: cash-destination partial below threshold is silent" { // A $15K cash lot with a $10K transfer attributed → residual // $5K, below the $10K threshold → silent. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{}; const after = [_]Lot{ .{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint trust" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-20,type::cash,amount:num:10000,from::Fidelity Emil,to::Joint trust,dest_lot::cash \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 23), .{ .transfer_log = tlog.transfers, }); const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 0), lots.len); } test "collectUnmatchedLargeLots: no new lots → empty result" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); const before = [_]Lot{}; const after = [_]Lot{}; const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 0), lots.len); } test "collectUnmatchedLargeLots: partial transfer still flags residual? No — full lot value counts" { // Partial transfers leave the Change as `partial_transfer_in`, // which the audit filter IGNORES (only new_* kinds pass the // `is_new_side` check). That's the correct behavior: the // unrecorded portion on a partial is typically small (pre- // existing cash) and already has an explicit transfer record // acknowledging the large movement. Surfacing it again would // double-nag. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("SYM", 500.0); const before = [_]Lot{}; // $50k lot, $45k from a transfer → partial_transfer_in with // $5k residual. Residual is below $10k threshold anyway, but // even if it weren't, the filter skips partial_transfer_in. const after = [_]Lot{ .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 \\transfer::2026-05-02,type::cash,amount:num:45000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03 \\ ); const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ .transfer_log = tlog.transfers, }); try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind); const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); try std.testing.expectEqual(@as(usize, 0), lots.len); } test "shortSha: HEAD passes through unchanged" { try std.testing.expectEqualStrings("HEAD", shortSha("HEAD")); try std.testing.expectEqualStrings("HEAD~", shortSha("HEAD~")); try std.testing.expectEqualStrings("HEAD~3", shortSha("HEAD~3")); } test "shortSha: long SHA truncates to 7 chars" { try std.testing.expectEqualStrings("abcdef0", shortSha("abcdef0123456789")); try std.testing.expectEqualStrings("a1b2c3d", shortSha("a1b2c3d4e5f6789012345")); } test "shortSha: short input returned as-is" { try std.testing.expectEqualStrings("abc", shortSha("abc")); try std.testing.expectEqualStrings("abcdefg", shortSha("abcdefg")); // exactly 7 try std.testing.expectEqualStrings("", shortSha("")); } test "specDisplayString: null yields '(unset)'" { var buf: [10]u8 = undefined; try std.testing.expectEqualStrings("(unset)", specDisplayString(null, &buf)); } test "specDisplayString: working_copy yields 'working'" { var buf: [10]u8 = undefined; try std.testing.expectEqualStrings("working", specDisplayString(.{ .working_copy = {} }, &buf)); } test "specDisplayString: git_ref returns ref verbatim" { var buf: [10]u8 = undefined; try std.testing.expectEqualStrings("HEAD", specDisplayString(.{ .git_ref = "HEAD" }, &buf)); try std.testing.expectEqualStrings("main", specDisplayString(.{ .git_ref = "main" }, &buf)); } test "specDisplayString: date_at_or_before formats date YYYY-MM-DD" { var buf: [10]u8 = undefined; const d = Date.fromYmd(2024, 3, 15); try std.testing.expectEqualStrings("2024-03-15", specDisplayString(.{ .date_at_or_before = d }, &buf)); } test "specLabel: null spec returns resolved ref dup'd" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const result = try specLabel(arena, null, "abc1234"); try std.testing.expectEqualStrings("abc1234", result); } test "specLabel: git_ref returns ref dup'd" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const result = try specLabel(arena, .{ .git_ref = "main" }, "ignored"); try std.testing.expectEqualStrings("main", result); } test "specLabel: date renders 'commit at-or-before YYYY-MM-DD'" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const d = Date.fromYmd(2024, 3, 15); const result = try specLabel(arena, .{ .date_at_or_before = d }, "ignored"); try std.testing.expectEqualStrings("commit at-or-before 2024-03-15", result); } test "specLabel: working_copy literal" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const result = try specLabel(arena, .{ .working_copy = {} }, "ignored"); try std.testing.expectEqualStrings("working copy", result); } test "specLabelAfter: null spec + non-null resolved_ref returns resolved_ref" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const result = try specLabelAfter(arena, null, "HEAD"); try std.testing.expectEqualStrings("HEAD", result); } test "specLabelAfter: null spec + null resolved_ref returns 'working copy'" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const result = try specLabelAfter(arena, null, null); try std.testing.expectEqualStrings("working copy", result); } test "specLabelAfter: spec set + null resolved_ref defaults to 'working'" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const result = try specLabelAfter(arena, .{ .working_copy = {} }, null); try std.testing.expectEqualStrings("working copy", result); } test "buildLabel: no date window, dirty -> 'Comparing working copy against HEAD'" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null }; const result = try buildLabel(arena, range, null, null, true); try std.testing.expectEqualStrings("Comparing working copy against HEAD", result); } test "buildLabel: no date window, clean -> HEAD~1 against HEAD" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null }; const result = try buildLabel(arena, range, null, null, false); try std.testing.expectEqualStrings("Working tree clean — comparing HEAD~1 against HEAD", result); } test "buildLabel: --since only, dirty -> against working copy" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null }; const since = Date.fromYmd(2024, 3, 15); const result = try buildLabel(arena, range, since, null, true); try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null); try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null); try std.testing.expect(std.mem.indexOf(u8, result, "working copy") != null); } test "buildLabel: --since only, clean -> against HEAD" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null }; const since = Date.fromYmd(2024, 3, 15); const result = try buildLabel(arena, range, since, null, false); try std.testing.expect(std.mem.indexOf(u8, result, "against HEAD") != null); try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null); } test "buildLabel: --since + --until renders both dates and short SHAs" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = "def4567890123" }; const since = Date.fromYmd(2024, 1, 15); const until = Date.fromYmd(2024, 3, 15); const result = try buildLabel(arena, range, since, until, false); try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null); try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null); try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null); try std.testing.expect(std.mem.indexOf(u8, result, "def4567") != null); } test "buildLabelFromSpecs: both date specs -> falls through to buildLabel" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "aaaaaaa1234567", .after_rev = "bbbbbbb1234567" }; const before_d = Date.fromYmd(2024, 1, 15); const after_d = Date.fromYmd(2024, 3, 15); const result = try buildLabelFromSpecs( arena, range, .{ .date_at_or_before = before_d }, .{ .date_at_or_before = after_d }, false, ); // Date-form path: uses buildLabel formatting try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null); try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null); } test "buildLabelFromSpecs: non-date spec -> ' vs ' format" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "main", .after_rev = "feature" }; const result = try buildLabelFromSpecs( arena, range, .{ .git_ref = "main" }, .{ .git_ref = "feature" }, false, ); try std.testing.expectEqualStrings("main vs feature", result); } test "buildLabelFromSpecs: working_copy after -> 'working copy' literal" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null }; const result = try buildLabelFromSpecs( arena, range, .{ .git_ref = "HEAD~1" }, .{ .working_copy = {} }, true, ); try std.testing.expectEqualStrings("HEAD~1 vs working copy", result); } test "printChangeLine: stock change shows shares × price = value" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const c = Change{ .kind = .new_stock, .symbol = "AAPL", .account = "Roth", .security_type = .stock, .delta_shares = 10, .unit_value = 150.0, }; try printChangeLine(&w, c, false, cli.CLR_POSITIVE); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Roth") != null); try std.testing.expect(std.mem.indexOf(u8, out, "shares") != null); try std.testing.expect(std.mem.indexOf(u8, out, "$150.00") != null); try std.testing.expect(std.mem.indexOf(u8, out, "$1,500.00") != null); } test "printChangeLine: cash change shows value only (no shares × price)" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const c = Change{ .kind = .new_cash, .symbol = "CASH", .account = "Brokerage", .security_type = .cash, .delta_shares = 1000, .unit_value = 1.0, }; try printChangeLine(&w, c, false, cli.CLR_POSITIVE); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "CASH") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage") != null); // cash shouldn't show "shares ×" try std.testing.expect(std.mem.indexOf(u8, out, "shares ×") == null); try std.testing.expect(std.mem.indexOf(u8, out, "$1,000.00") != null); } test "printChangeLine: empty account shown as '(no account)'" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const c = Change{ .kind = .new_stock, .symbol = "VTI", .account = "", .security_type = .stock, .delta_shares = 5, .unit_value = 200.0, }; try printChangeLine(&w, c, false, cli.CLR_POSITIVE); try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "(no account)") != null); } test "printSummaryCell: zero value renders muted dash" { var buf: [128]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSummaryCell(&w, "Drip", 0, false); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Drip") != null); try std.testing.expect(std.mem.indexOf(u8, out, "-") != null); } test "printSummaryCell: nonzero value renders dollar amount" { var buf: [128]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSummaryCell(&w, "Drip", 250.50, false); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Drip") != null); try std.testing.expect(std.mem.indexOf(u8, out, "$250.50") != null); } test "printSection: emits title with header style" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSection(&w, "Contributions", false, cli.CLR_POSITIVE); try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "Contributions") != null); } test "printNone: emits muted '(none)' line" { var buf: [128]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printNone(&w, false, cli.CLR_MUTED); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "none") != null or std.mem.indexOf(u8, out, "None") != null); } test "printTotalLine: emits label and dollar amount" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printTotalLine(&w, "Total:", 12_345.67, false, cli.CLR_POSITIVE); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Total") != null); try std.testing.expect(std.mem.indexOf(u8, out, "$12,345.67") != null); } test "printPriceOnlyLine: shows old → new price" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const c = Change{ .kind = .price_only, .symbol = "VTI", .account = "Roth", .security_type = .stock, .old_price = 100.0, .new_price = 110.0, }; try printPriceOnlyLine(&w, c, false, cli.CLR_MUTED); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null); try std.testing.expect(std.mem.indexOf(u8, out, "$100.00") != null); try std.testing.expect(std.mem.indexOf(u8, out, "$110.00") != null); } test "printChangeLine: no ANSI when color=false" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const c = Change{ .kind = .new_stock, .symbol = "AAPL", .account = "Roth", .security_type = .stock, .delta_shares = 10, .unit_value = 150.0, }; try printChangeLine(&w, c, false, cli.CLR_POSITIVE); try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null); }