From 7729ac8d7e9c4c0218354612a6d5fae0db324b33 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 7 May 2026 10:43:59 -0700 Subject: [PATCH] initial transfer capability on contributions --- src/commands/contributions.zig | 1190 +++++++++++++++++++++++++++++++- src/main.zig | 1 - 2 files changed, 1188 insertions(+), 3 deletions(-) diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 8c6447f..34978a7 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -112,6 +112,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const git = @import("../git.zig"); const analysis = @import("../analytics/analysis.zig"); +const transaction_log = @import("../models/transaction_log.zig"); const fmt = cli.fmt; const Date = zfin.Date; const Lot = zfin.Lot; @@ -298,13 +299,40 @@ fn prepareReport( var account_map_opt = svc.loadAccountMap(portfolio_path); defer if (account_map_opt) |*am| am.deinit(); + // Load transaction_log.srf (if present) so the matchTransfers + // pass can reclassify destination/source Changes. Absent file → + // no-op. Derive window endpoints from the committer-date of the + // before/after revs; fall back to today when the after side is + // working-copy or when the git timestamp lookup fails (failure + // silently degrades to an unbounded-above window). + var transfer_log_opt = svc.loadTransferLog(portfolio_path); + defer if (transfer_log_opt) |*tl| tl.deinit(); + + const window_start: ?Date = blk: { + const ts = git.commitTimestamp(arena, repo.root, endpoints.range.before_rev) catch break :blk null; + break :blk Date.fromEpoch(ts); + }; + const window_end: ?Date = blk: { + if (endpoints.range.after_rev) |rev| { + const ts = git.commitTimestamp(arena, repo.root, rev) catch break :blk fmt.todayDate(); + break :blk Date.fromEpoch(ts); + } else { + break :blk fmt.todayDate(); + } + }; + const report = computeReport( arena, before_pf.lots, after_pf.lots, &prices, fmt.todayDate(), - .{ .account_map = if (account_map_opt) |*am| am else null }, + .{ + .account_map = if (account_map_opt) |*am| am else null, + .transfer_log = if (transfer_log_opt) |*tl| tl else null, + .window_start = window_start, + .window_end = window_end, + }, ) catch { if (verbosity == .verbose) cli.stderrPrint("Error computing contributions diff.\n") catch {}; return error.PrepareFailed; @@ -670,6 +698,10 @@ 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 @@ -679,14 +711,29 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary { // (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.value(), .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(), + .partial_transfer_in => new_contributions += c.attributedValue(), else => {}, }; + // Subtract cash-dest transfer attribution. The matcher populates + // `cash_attributed_by_account` with the per-account total of cash + // transfers; those amounts already flowed into `new_contributions` + // above via their `new_cash` / `cash_contribution` Change and need + // to be taken back out. Single-summed subtraction is safe because + // the matcher's budget check guarantees the bucket doesn't exceed + // the pool of positive cash Changes on each account. + var cait = ctx.report.cash_attributed_by_account.iterator(); + while (cait.next()) |entry| { + new_contributions -= entry.value_ptr.*; + } + return .{ .new_contributions = new_contributions, .drip = drip }; } @@ -731,6 +778,30 @@ const ChangeKind = enum { 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 { @@ -752,9 +823,40 @@ const Change = struct { /// Free-form detail for flagged changes. detail: ?[]const u8 = 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. 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, + else => self.value(), + }; + } }; /// Summary aggregated for the report. All string fields and backing memory @@ -763,6 +865,11 @@ 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) @@ -1053,6 +1160,20 @@ const ReportOptions = struct { /// `cash_contribution` so every downstream consumer (full /// report, per-account summary, `compare` attribution) agrees. account_map: ?*const analysis.AccountMap = null, + /// Transfer log from `transaction_log.srf`. When present, the + /// `matchTransfers` pass 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, `matchTransfers` is a no-op. + transfer_log: ?*const transaction_log.TransactionLog = null, + /// Window start — inclusive. Only transfer records with + /// `transfer >= window_start` are considered. Null when + /// `transfer_log` is null. + window_start: ?Date = null, + /// Window end — inclusive. Only transfer records with + /// `transfer <= window_end` are considered. Null when + /// `transfer_log` is null. + window_end: ?Date = null, }; fn computeReport( @@ -1266,6 +1387,24 @@ fn computeReport( }); } + // Transfer reclassification pass: rewrite destination/source + // Change kinds for records in `transaction_log.srf` that match + // the window, and accumulate per-account cash attribution so + // transferred cash doesn't double-count in per-account totals. + // No-op when no transfer_log is supplied. See `matchTransfers` + // docstring for the matching algorithm. + var cash_attributed_by_account: std.StringHashMap(f64) = .init(allocator); + if (opts.transfer_log) |tlog| { + try matchTransfers( + allocator, + &changes, + &cash_attributed_by_account, + tlog, + opts.window_start, + opts.window_end, + ); + } + // Build per-account totals. var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator); @@ -1289,13 +1428,413 @@ fn computeReport( // 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 => {}, } } + // Subtract cash-dest transfer attribution from each account's + // cash-side totals so transferred cash doesn't inflate + // new_money / cash_delta / cash_contribution. The bucket was + // populated by the cash-dest matcher; each account's entry is + // the sum of matched record amounts landing on that account. + // + // Apply to new_money (new_cash contributed there) and + // cash_delta (pooled unclassified); we don't have visibility + // here into WHICH of those buckets held the attributed dollars, + // so take from new_money first (where cash_contribution lands) + // then cash_delta. This matches how `summarizeAttribution` + // handles the same subtraction at the totals level. + var cait = cash_attributed_by_account.iterator(); + while (cait.next()) |entry| { + const acct = entry.key_ptr.*; + var remaining = entry.value_ptr.*; + if (acct_totals.getPtr(acct)) |at| { + const from_new_money = @min(at.new_money, remaining); + at.new_money -= from_new_money; + remaining -= from_new_money; + if (remaining > 0) { + const from_cash_delta = @min(at.cash_delta, remaining); + at.cash_delta -= from_cash_delta; + remaining -= from_cash_delta; + } + // Any leftover `remaining` means the attribution bucket + // exceeded both buckets — shouldn't happen if the + // matcher's budget check holds, but harmless if it does + // (the overage just doesn't land anywhere negative). + } + } + 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; + +/// 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 silently skipped — they belong to a +/// different diff. When either window endpoint is null, that side +/// is unbounded. +/// +/// 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), + tlog: *const transaction_log.TransactionLog, + window_start: ?Date, + window_end: ?Date, +) !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 file order. + const records = try tlog.transfersInWindow( + allocator, + window_start orelse Date{ .days = std.math.minInt(i32) }, + window_end orelse Date{ .days = std.math.maxInt(i32) }, + ); + defer allocator.free(records); + + 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; + + // Append a synthetic transfer_in Change for Transfers-section + // display. We deliberately do NOT flip an existing cash + // Change's kind: a single cash_delta can be drained by multiple + // records, and the kind field can only hold one classification. + // Keeping the original cash Change intact preserves its + // appearance in the "Cash deltas" section of the report; the + // per-account totals reconciliation handles the attribution + // math via `cash_attributed_by_account`. + 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 => {}, }; } @@ -1332,6 +1871,18 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co new_total += c.value(); 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); @@ -1413,6 +1964,34 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co 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) { @@ -1463,7 +2042,7 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co // ── Section: Flagged ── var any_flag = false; for (report.changes) |c| switch (c.kind) { - .flagged, .lot_removed, .drip_negative => any_flag = true, + .flagged, .lot_removed, .drip_negative, .unmatched_transfer => any_flag = true, else => {}, }; if (any_flag) { @@ -1472,6 +2051,9 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co .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"); @@ -1657,6 +2239,123 @@ fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) } } +// ── 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| d.format(&date_buf) else "????-??-??"; + var val_buf: [32]u8 = undefined; + const val_str = fmt.fmtMoneyAbs(&val_buf, c.transfer_attributed); + + 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) { + var lot_buf: [32]u8 = undefined; + var res_buf: [32]u8 = undefined; + const lot_value = c.value(); + const residual = lot_value - c.transfer_attributed; + try cli.printFg( + out, + color, + muted, + " → {s} ({s} of {s} lot — {s} from pre-existing cash)\n", + .{ + if (c.symbol.len > 0) c.symbol else "cash", + fmt.fmtMoneyAbs(&val_buf, c.transfer_attributed), + fmt.fmtMoneyAbs(&lot_buf, lot_value), + fmt.fmtMoneyAbs(&res_buf, 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| d.format(&date_buf) else "????-??-??"; + var val_buf: [32]u8 = undefined; + const val_str = fmt.fmtMoneyAbs(&val_buf, c.transfer_attributed); + 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 { + var val_buf: [32]u8 = undefined; + var lot_buf: [32]u8 = undefined; + 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, " {s}", .{fmt.fmtMoneyAbs(&val_buf, residual)}); + try cli.printFg( + out, + color, + muted, + " (of {s} total — rest from transfer)\n", + .{fmt.fmtMoneyAbs(&lot_buf, lot_value)}, + ); +} + // ── Tests ──────────────────────────────────────────────────── test "computeReport: fresh stock purchase counts as new contribution" { @@ -2652,3 +3351,490 @@ test "short: SHA-256 length also truncates to 7" { test "short: short input returned as-is" { try std.testing.expectEqualStrings("abc", short("abc")); } + +// ── matchTransfers tests (M2) ──────────────────────────────── +// +// 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 "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" }, + }; + + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + // 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" }, + }; + + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + 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" }, + }; + + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + 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" }, + }; + + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + // 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". + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + 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{}; + + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + 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. + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + // 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" }, + }; + + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + 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). + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + // 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" }, + }; + + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + // 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: transfer outside window is ignored" { + 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" }, + }; + + // Record is before the window start — should be silently skipped. + var 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, + .window_start = Date.fromYmd(2026, 4, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + // Record skipped → lot stays new_stock, no unmatched. + try std.testing.expectEqual(@as(usize, 1), report.changes.len); + try std.testing.expectEqual(ChangeKind.new_stock, 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 — same behavior as before M2. + 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" }, + }; + + var 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, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + // 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); +} diff --git a/src/main.zig b/src/main.zig index 87fd688..b1c5a13 100644 --- a/src/main.zig +++ b/src/main.zig @@ -792,5 +792,4 @@ test "parseGlobals: subcommand-local flag NOT consumed as global" { // See AGENTS.md → "Adding tests" for details. test { std.testing.refAllDeclsRecursive(@This()); - _ = @import("models/transaction_log.zig"); }