//! `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. //! //! Classifies each lot-level change as: //! - New contribution (new lot, or cash increase for a fresh cash line) //! - DRIP / reinvestment (same (symbol, account, open_date, open_price), Δshares) //! - CD matured (CD removed with maturity_date <= today) //! - CD removed early (CD removed with maturity_date > today — flagged) //! - Cash delta (cash shares changed on existing line) //! - Price-only update (manual price:: field changed, no share change) //! - Flagged (open_price, maturity, account or other edits) //! //! 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 fmt = cli.fmt; 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 fn run( allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, since: ?Date, until: ?Date, color: bool, 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 "--until without --since is ambiguous" rule at the // entry point so `resolveEndpoints`/`git.resolveCommitRange` can // assume the invariant. if (since == null and until != null) { try cli.stderrPrint("Error: --until requires --since. Use `contributions --since ` or both.\n"); return; } var ctx = prepareReport(allocator, arena, svc, portfolio_path, since, until, color, .verbose) catch return; defer ctx.deinit(); try printReport(out, &ctx.report, ctx.endpoints.label, color); try out.flush(); } /// Shared pipeline context: everything `run` and `computeAttribution` /// 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( allocator: std.mem.Allocator, arena: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, since: ?Date, until: ?Date, color: bool, verbosity: Verbosity, ) PrepareError!ReportContext { const repo = git.findRepo(arena, portfolio_path) catch |err| { if (verbosity == .verbose) { switch (err) { error.NotInGitRepo => cli.stderrPrint("Error: contributions requires portfolio.srf to be in a git repo.\n") catch {}, error.GitUnavailable => cli.stderrPrint("Error: could not run 'git'. Is git installed and on PATH?\n") catch {}, else => cli.stderrPrint("Error locating git repo.\n") catch {}, } } return error.PrepareFailed; }; const status = git.pathStatus(arena, repo.root, repo.rel_path) catch { if (verbosity == .verbose) cli.stderrPrint("Error: could not determine git status of portfolio.srf.\n") catch {}; return error.PrepareFailed; }; if (status == .untracked) { if (verbosity == .verbose) cli.stderrPrint("Error: portfolio.srf is not tracked in git. Add and commit it first.\n") catch {}; return error.PrepareFailed; } const dirty = status == .modified; const endpoints = resolveEndpoints(arena, repo, since, until, 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(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(msg) catch {}; } return error.PrepareFailed; }; const after = if (endpoints.range.after_rev) |rev| git.show(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(msg) catch {}; } return error.PrepareFailed; } else std.fs.cwd().readFileAlloc(arena, portfolio_path, 10 * 1024 * 1024) catch { if (verbosity == .verbose) cli.stderrPrint("Error reading working-copy portfolio file.\n") catch {}; return error.PrepareFailed; }; var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch { if (verbosity == .verbose) cli.stderrPrint("Error parsing before-snapshot portfolio.\n") catch {}; return error.PrepareFailed; }; errdefer before_pf.deinit(); var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch { if (verbosity == .verbose) cli.stderrPrint("Error parsing after-snapshot portfolio.\n") catch {}; 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(svc, syms.items, &.{}, false, 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; } } const report = computeReport(arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate()) catch { if (verbosity == .verbose) cli.stderrPrint("Error computing contributions diff.\n") catch {}; 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 `computeAttribution` 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( arena: std.mem.Allocator, repo: git.RepoInfo, since: ?Date, until: ?Date, dirty: bool, verbosity: Verbosity, ) !Endpoints { const range = git.resolveCommitRange(arena, repo, since, until, dirty) catch |err| { if (verbosity == .verbose) { switch (err) { error.NoCommitAtOrBefore => { // Report which flag triggered it. When both are set // we can't easily tell from here; message covers both. var since_buf: [10]u8 = undefined; const since_str = if (since) |s| s.format(&since_buf) else "(unset)"; 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, since_str }) catch "Error: no commit at or before requested date.\n"; try cli.stderrPrint(msg); }, else => { try cli.stderrPrint("Error resolving commit range: "); try cli.stderrPrint(@errorName(err)); try cli.stderrPrint("\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 buildLabel(arena, range, since, until, 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 (since != null and until != null and verbosity == .verbose) { if (range.after_rev) |after_rev| { if (std.mem.eql(u8, range.before_rev, after_rev)) { try cli.stderrPrint("Warning: --since and --until resolve to the same commit; no changes to report.\n"); } } } return .{ .range = range, .label = label }; } /// 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 = since.?.format(&since_buf); if (until) |until_date| { var until_buf: [10]u8 = undefined; const until_str = until_date.format(&until_buf); 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 date 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. pub fn computeAttribution( allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, since: ?Date, until: ?Date, color: bool, ) ?AttributionSummary { // `--until` without `--since` is ambiguous; caller is expected to // enforce this at the entry point, but guard here too — the // prepareReport path sends it through `git.resolveCommitRange` // which asserts the invariant. if (since == null and until != null) return null; var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var ctx = prepareReport(allocator, arena, svc, portfolio_path, since, until, color, .silent) catch return null; defer ctx.deinit(); // Aggregate. Classification logic matches the full-report sections: // - New contributions: new_stock + new_cash + new_cd + new_option // - 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. 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 => new_contributions += c.value(), .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(), else => {}, }; 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 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 price_only, // same key, price:: field changed, no share change flagged, // any other shape of edit }; 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, pub fn value(self: Change) f64 { return self.delta_shares * self.unit_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), 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 { var open_date_buf: [10]u8 = undefined; return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{s}|{d:.6}", .{ @tagName(lot.security_type), lot.symbol, lot.account orelse "", lot.open_date.format(&open_date_buf), 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; } fn computeReport( allocator: std.mem.Allocator, before: []const Lot, after: []const Lot, prices: *const std.StringHashMap(f64), today: Date, ) !Report { var changes: std.ArrayList(Change) = .empty; var before_map = try aggregateByKey(allocator, before); var after_map = try aggregateByKey(allocator, after); // 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| { 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) { const before_lot = before_agg.lot; const is_drip = lot.drip or before_lot.drip; const 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, }; // 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| d.format(&old_buf) else "(none)"; const new_str = if (lot.maturity_date) |d| d.format(&new_buf) 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, }); } } // 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; 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 <= today (i.e. NOT today.lessThan(mat)) if (!today.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, }); } // 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 => { gop.value_ptr.new_money += c.value(); }, .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.value(); }, .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). }, else => {}, } } return .{ .changes = try changes.toOwnedSlice(allocator), .account_totals = acct_totals, }; } // ── 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 => { any = true; new_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", 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: 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: Flagged ── var any_flag = false; for (report.changes) |c| switch (c.kind) { .flagged, .lot_removed, .drip_negative => 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); }, 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", .{}); var buf1: [32]u8 = undefined; var buf2: [32]u8 = undefined; var buf3: [32]u8 = undefined; var buf4: [32]u8 = undefined; try out.print(" New contributions / purchases: {s}\n", .{fmt.fmtMoneyAbs(&buf1, total_new)}); try out.print(" DRIP (confirmed): {s}\n", .{fmt.fmtMoneyAbs(&buf2, total_drip)}); try out.print(" Rollup share deltas: {s} (DRIP or contribution; can't distinguish)\n", .{fmt.fmtMoneyAbs(&buf3, total_rollup)}); if (total_cd_int > 0) { try out.print(" CD interest captured: {s}\n", .{fmt.fmtMoneyAbs(&buf4, total_cd_int)}); } } 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 { var buf: [32]u8 = undefined; try cli.printFg(out, color, hdr, " {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, 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 = fmt.fmtMoneyAbs(&price_buf, c.unit_value); const val_str = fmt.fmtMoneyAbs(&val_buf, c.value()); 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 face_buf: [32]u8 = undefined; var mat_buf: [10]u8 = undefined; const mat_str = if (c.maturity_date) |d| d.format(&mat_buf) 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 {s} maturity {s}\n", .{ c.symbol, acct, verb, fmt.fmtMoneyAbs(&face_buf, c.face_value), mat_str, }); if (implied_interest) |i| { var int_buf: [32]u8 = undefined; try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) }); } } fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, color: bool) !void { var val_buf: [32]u8 = undefined; 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}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @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)) { var face_buf: [32]u8 = undefined; try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)}); break; } } try out.writeAll("\n"); } fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { var old_buf: [32]u8 = undefined; var new_buf: [32]u8 = undefined; const acct = if (c.account.len == 0) "(no account)" else c.account; try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {s} → {s}\n", .{ c.symbol, acct, fmt.fmtMoneyAbs(&old_buf, c.old_price), fmt.fmtMoneyAbs(&new_buf, 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 => { var face_buf: [32]u8 = undefined; try out.print(" {s:<14}{s:<24} {s} lot removed (face {s})", .{ c.symbol, acct, @tagName(c.security_type), fmt.fmtMoneyAbs(&face_buf, c.face_value), }); }, .drip_negative => { var val_buf: [32]u8 = undefined; try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({s})", .{ c.symbol, acct, fmt.fmtMoneyAbs(&val_buf, @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 { var buf: [32]u8 = undefined; 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, "{s:>12}", .{fmt.fmtMoneyAbs(&buf, v)}); } } // ── Tests ──────────────────────────────────────────────────── 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: 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 = "Riley 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 = "Riley IRA", .drip = true }, }; const after = [_]Lot{ .{ .symbol = "FAGIX", .shares = 105, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 11.00, .account = "Riley 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(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(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")); }