From 7e9261f92fc74c4b73c0d0f00478ed961f22977e Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 25 Jun 2026 13:49:29 -0700 Subject: [PATCH] add in-kind transfer capability --- .pre-commit-config.yaml | 2 +- TODO.md | 22 -- src/commands/audit/hygiene.zig | 6 +- src/commands/contributions.zig | 611 ++++++++++++++++++++++++++++++++- src/models/transaction_log.zig | 19 +- 5 files changed, 614 insertions(+), 46 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2d81840..eba5562 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=77"] + args: ["build", "coverage", "-Dcoverage-threshold=78"] language: system types: [file] pass_filenames: false diff --git a/TODO.md b/TODO.md index 8374688..82794ed 100644 --- a/TODO.md +++ b/TODO.md @@ -168,28 +168,6 @@ opts ESPP/HSA accounts into cash-based attribution. Related: ESPP-style accrual blind spot in the "Audit: manual-check accounts mechanism" section above. -## In-kind transfer support (`type::in_kind`) - priority MEDIUM - -`transaction_log.srf` parses `type::in_kind` records but the -contributions matcher always rejects them with "in-kind transfers -not yet supported in v1." In-kind movements need per-symbol -matching across accounts: an in-kind transfer of 100 VTI shares -from Acct A to Acct B shows up as `lot_removed` on A + `new_stock` -on B (or a `rollup_delta` share increase if B already had a VTI -lot), neither of which can be matched by the current -amount-based cash matcher. - -Proposed: a second pass in `matchTransfers` that iterates -`type::in_kind` records and looks for same-symbol matches across -`lot_removed` on `from` + `new_stock`/`rollup_delta` on `to` -within the window. Gated on share-count and open_price sanity so -a partial transfer doesn't false-positive against an unrelated -edit. - -Driver: when the user starts moving positions between accounts -directly (e.g. Roth conversion of already-held shares, 401k -> -rollover IRA in-kind) rather than liquidating and re-buying. - ## Torn SRF files from server sync (root cause unknown) **Status:** Root cause still unidentified. We have mitigations and diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index f94a9c6..8f36e28 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -442,8 +442,10 @@ fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool { /// /// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash /// (or cash_contribution) destinations use `dest_lot::cash`. The -/// template always uses `type::cash` since `type::in_kind` is -/// rejected downstream in v1. +/// template defaults to `type::cash` (cash moved in, then invested - +/// the common case). If the securities themselves were moved between +/// accounts, change it to `type::in_kind` and fill in the `from::` +/// account that the shares left. fn printLargeLotWarning( out: *std.Io.Writer, lot: contributions.UnmatchedLargeLot, diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 556a41e..37579a0 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -130,12 +130,20 @@ //! a reason string; the transfer amount //! stays out of attribution either way. //! -//! | Scenario | Kind | Section | In Grand Total | In Attribution | -//! |-----------------------------------------------|------------------------|------------------|:--------------:|:--------------:| -//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no | -//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual | -//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no | -//! | Record with no match (bad dest, in_kind, ...) | `unmatched_transfer` | Flagged | no | no | +//! | Scenario | Kind | Section | In Grand Total | In Attribution | +//! |-------------------------------------------------|--------------------------------|------------------------------------------|:--------------:|:--------------:| +//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no | +//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual | +//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no | +//! | In-kind securities moved between accounts | `transfer_in` + `transfer_out` | Transfers | no | no | +//! | Record with no match (bad dest, mismatch, ...) | `unmatched_transfer` | Flagged | no | no | +//! +//! `type::cash` records and `type::in_kind` records take different +//! matching passes. Cash records match a cash budget / pooled +//! destination (see `matchCashDestination` / `matchLotDestination`). +//! In-kind records (securities moved without cash changing hands) +//! pair a source share-removal Change against a destination share- +//! addition Change on a per-symbol basis (see `matchInKindTransfer`). //! //! Cash-destination records don't flip the original `cash_delta` / //! `new_cash` Change (a single cash delta can be drained by @@ -1863,6 +1871,20 @@ fn computeReport( /// dollars for cash lots) without masking real discrepancies. const transfer_amount_tolerance: f64 = 1.0; +/// Relative share-count tolerance for confirming an in-kind transfer: +/// the shares removed on the `from` account must match the shares +/// added on the `to` account to within this fraction of the larger +/// of the two. Brokerages sometimes liquidate a fractional share on +/// an in-kind move (whole shares transfer, the residual fraction is +/// swept to cash), so a small relative drift is expected. Mirrors the +/// 1% direct-indexing drift tolerance used elsewhere in the diff. +const in_kind_share_rel_tolerance: f64 = 0.01; + +/// Absolute floor for the in-kind share-count tolerance, so a small +/// transfer (a one-share move) still gets a sane float-rounding +/// epsilon even though 1% of one share is tiny. +const in_kind_share_abs_tolerance: f64 = 0.01; + /// Compute the slice of transfer records that are NEW in `after` /// relative to `before` - i.e. records the matcher should consider /// for the current diff. Records present in both logs are skipped @@ -1904,8 +1926,11 @@ fn diffTransferLogs( /// 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`): +/// - For each record with `type::in_kind`: pair a source share- +/// removal Change on `from` against a destination share-addition +/// Change on `to`, per-symbol (see `matchInKindTransfer`). +/// +/// - For each record with `type::cash`: /// - 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 @@ -1989,7 +2014,12 @@ fn matchTransfers( // after-side `transaction_log.srf` vs. the before-side). for (records) |rec| { if (rec.type == .in_kind) { - try appendUnmatched(allocator, changes, rec, "in-kind transfers not yet supported in v1"); + // In-kind transfers move securities, not cash, so they + // take a separate per-symbol matching pass (source + // `lot_removed`/`drip_negative` + destination + // `new_stock`/`new_drip_lot`/`rollup_delta`) rather than + // the cash budget / from-side path below. + try matchInKindTransfer(allocator, changes, rec); continue; } @@ -2277,6 +2307,139 @@ fn tryMatchFromSide( }; } +/// Match a `type::in_kind` transfer record: securities moved between +/// accounts without any cash changing hands. Unlike the cash path, +/// both the source and destination are lot-level Changes: +/// +/// - **Destination** (required): a `new_stock` / `new_drip_lot` +/// (the shares landed as a fresh lot on `to`) or a `rollup_delta` +/// (the shares were added to an existing `to` lot). Identified by +/// the record's `dest_lot::SYMBOL@DATE`. Reclassified to +/// `transfer_in` so it contributes $0 to attribution - it's +/// internal movement, not new money. +/// - **Source** (best-effort): a `lot_removed` / `drip_negative` on +/// the `from` account with the same symbol. Reclassified to +/// `transfer_out`. A missing source is NOT an error - the sending +/// account may be untracked (an external rollover origin), the +/// same tolerance the cash from-side path extends. +/// +/// When both sides are present the shares removed must match the +/// shares added to within `in_kind_share_*_tolerance`; a mismatch +/// means the declared transfer doesn't cleanly correspond to the +/// portfolio diff (wrong symbol, unexpected partial fill) and is +/// surfaced as `unmatched_transfer` rather than silently swallowing a +/// real contribution. +/// +/// `amount` on an in-kind record is informational - the moved value +/// comes from the destination lot's own `value()` (shares x cost +/// basis), which is what `transfer_attributed` records for display. +/// In-kind movements have no "residual new money" concept (no cash +/// funded them), so there is no `partial_transfer_in` outcome: a +/// destination Change is either fully a transfer or not one at all. +fn matchInKindTransfer( + allocator: std.mem.Allocator, + changes: *std.ArrayList(Change), + rec: transaction_log.TransferRecord, +) !void { + const dl: transaction_log.DestLot.LotRef = switch (rec.dest_lot) { + .lot => |l| l, + .cash => { + try appendUnmatched(allocator, changes, rec, "in-kind transfer requires a SYMBOL@DATE destination lot, not cash"); + return; + }, + }; + + // ── Destination (required) ─────────────────────────────────── + // First share-addition Change on the `to` account for this + // symbol. Keyed on (account, symbol, kind); open_date is + // consulted only for display (Change doesn't carry it for + // rollup_delta). Once matched, a Change is flipped to transfer_in, + // so a second record naming the same lot won't re-match it (it's + // no longer a destination kind) and falls through to not-found. + var dest_idx: ?usize = null; + 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; + switch (c.kind) { + .new_stock, .new_drip_lot, .rollup_delta => {}, + else => continue, + } + dest_idx = i; + break; + } + + const di = dest_idx orelse { + const buf = try std.fmt.allocPrint( + allocator, + "in-kind destination lot {s}@ not found on account {s} (expected a new lot or share increase; an earlier record may have claimed it)", + .{ dl.symbol, rec.to }, + ); + try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf); + return; + }; + + // ── Source (best-effort) ───────────────────────────────────── + // First share-removal Change on the `from` account for this + // symbol. A missing source is not an error - the sending account + // may be untracked (external rollover origin). + var src_idx: ?usize = null; + for (changes.items, 0..) |c, i| { + if (!std.mem.eql(u8, c.account, rec.from)) continue; + if (!std.mem.eql(u8, c.symbol, dl.symbol)) continue; + switch (c.kind) { + .lot_removed, .drip_negative => {}, + else => continue, + } + src_idx = i; + break; + } + + // ── Share-count gate (only when both sides are present) ────── + if (src_idx) |si| { + const dest_shares = @abs(changes.items[di].delta_shares); + const src_shares = @abs(changes.items[si].delta_shares); + const tol = @max( + in_kind_share_abs_tolerance, + in_kind_share_rel_tolerance * @max(dest_shares, src_shares), + ); + if (@abs(dest_shares - src_shares) > tol) { + const buf = try std.fmt.allocPrint( + allocator, + "in-kind share mismatch for {s}: {d:.4} removed from {s} vs {d:.4} added to {s}", + .{ dl.symbol, src_shares, rec.from, dest_shares, rec.to }, + ); + try appendUnmatchedWithOwnedNote(allocator, changes, rec, buf); + return; + } + } + + // ── Reclassify ─────────────────────────────────────────────── + // Dupe owned strings before mutating so an allocation failure + // doesn't leave a half-rewritten Change. The moved value is the + // destination lot's own value (shares x cost basis), not the + // record's `amount` (which is an informational annotation). + 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); + const moved_value = changes.items[di].value(); + + const dest = &changes.items[di]; + dest.kind = .transfer_in; + dest.transfer_attributed = moved_value; + dest.transfer_note = note_copy; + dest.transfer_from = from_copy; + dest.transfer_date = rec.transfer; + + if (src_idx) |si| { + const src = &changes.items[si]; + src.kind = .transfer_out; + // The from-side display reads `transfer_attributed` for the + // moved value and `account` for the sending account; it does + // not read `transfer_from` (see `printTransferLine`). + src.transfer_attributed = moved_value; + src.transfer_date = rec.transfer; + } +} + // ── Output ─────────────────────────────────────────────────── fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void { @@ -4494,7 +4657,11 @@ test "matchTransfers: same-day multi-cash records drain a single cash_delta" { try std.testing.expectApproxEqAbs(@as(f64, 0.0), t.new_money, 0.01); } -test "matchTransfers: type::in_kind always emits unmatched" { +test "matchInKindTransfer: happy path - new lot on dest, lot_removed on source" { + // Roth-conversion shape: 80 shares of SYM move in-kind from + // Acct A (tracked) to Acct B. A's lot disappears (lot_removed), + // B gains a fresh lot (new_stock). Both flip to transfer kinds + // and contribute $0 to attribution. var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const allocator = arena_state.allocator(); @@ -4502,8 +4669,9 @@ test "matchTransfers: type::in_kind always emits unmatched" { 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 before = [_]Lot{ + .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" }, + }; const after = [_]Lot{ .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, }; @@ -4518,18 +4686,435 @@ test "matchTransfers: type::in_kind always emits unmatched" { .transfer_log = tlog.transfers, }); - // new_stock stays as new_stock (not flipped); unmatched appended. + var n_transfer_in: usize = 0; + var n_transfer_out: usize = 0; var n_new_stock: usize = 0; + var n_lot_removed: usize = 0; var n_unmatched: usize = 0; for (report.changes) |c| switch (c.kind) { + .transfer_in => n_transfer_in += 1, + .transfer_out => n_transfer_out += 1, .new_stock => n_new_stock += 1, + .lot_removed => n_lot_removed += 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_transfer_out); + try std.testing.expectEqual(@as(usize, 0), n_new_stock); + try std.testing.expectEqual(@as(usize, 0), n_lot_removed); + try std.testing.expectEqual(@as(usize, 0), n_unmatched); + + // Dest moved value is the lot's own value (80 x $100 = $8,000). + for (report.changes) |c| { + if (c.kind == .transfer_in) { + try std.testing.expectApproxEqAbs(@as(f64, 8000.0), c.transfer_attributed, 0.01); + try std.testing.expectEqualStrings("Acct A", c.transfer_from.?); + } + } + + // No new money on either account. + try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01); +} + +test "matchInKindTransfer: into existing dest lot (rollup_delta) flips to transfer_in" { + // B already held SYM; the in-kind add shows up as a rollup_delta + // (share increase on the existing lot), not a 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(); + try prices.put("SYM", 100.0); + + const before = [_]Lot{ + .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" }, + .{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 80, .account = "Acct B" }, + }; + const after = [_]Lot{ + // Acct A's lot gone; Acct B's lot grew by 100 shares (same key). + .{ .symbol = "SYM", .shares = 150, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 80, .account = "Acct B" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2024-01-01 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + var n_transfer_in: usize = 0; + var n_transfer_out: usize = 0; + var n_rollup: usize = 0; + var n_unmatched: usize = 0; + for (report.changes) |c| switch (c.kind) { + .transfer_in => n_transfer_in += 1, + .transfer_out => n_transfer_out += 1, + .rollup_delta => n_rollup += 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_transfer_out); + try std.testing.expectEqual(@as(usize, 0), n_rollup); + try std.testing.expectEqual(@as(usize, 0), n_unmatched); + + // Rollup share delta is no longer counted on Acct B. + try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.rollup, 0.01); +} + +test "matchInKindTransfer: partial move (drip_negative source) matches" { + // 40 of A's 100 SYM shares move to B. A's lot shrinks + // (drip_negative); B gains a new lot. Share counts 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", 100.0); + + const before = [_]Lot{ + .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" }, + }; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 90, .account = "Acct A" }, + .{ .symbol = "SYM", .shares = 40, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 90, .account = "Acct B" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:4000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + var n_transfer_in: usize = 0; + var n_transfer_out: usize = 0; + var n_unmatched: usize = 0; + for (report.changes) |c| switch (c.kind) { + .transfer_in => n_transfer_in += 1, + .transfer_out => n_transfer_out += 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_transfer_out); + try std.testing.expectEqual(@as(usize, 0), n_unmatched); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01); +} + +test "matchInKindTransfer: untracked source still flips destination" { + // The `from` account isn't in the portfolio (external rollover + // origin). The destination still reclassifies to transfer_in - + // the user declared the move, so it isn't new external money. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SYM", 100.0); + + const before = [_]Lot{}; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:8000,from::External Rollover,to::Acct B,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + var n_transfer_in: usize = 0; + var n_transfer_out: usize = 0; + var n_unmatched: usize = 0; + for (report.changes) |c| switch (c.kind) { + .transfer_in => n_transfer_in += 1, + .transfer_out => n_transfer_out += 1, + .unmatched_transfer => n_unmatched += 1, + else => {}, + }; + try std.testing.expectEqual(@as(usize, 1), n_transfer_in); + try std.testing.expectEqual(@as(usize, 0), n_transfer_out); // no tracked source + try std.testing.expectEqual(@as(usize, 0), n_unmatched); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), report.account_totals.get("Acct B").?.new_money, 0.01); +} + +test "matchInKindTransfer: share-count mismatch emits unmatched, leaves Changes counted" { + // A removes 100 SYM but B only gains 73 SYM - the declared + // transfer doesn't cleanly correspond to the diff. The matcher + // refuses to pair them: both Changes keep their base kinds and an + // unmatched_transfer 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(); + try prices.put("SYM", 100.0); + + const before = [_]Lot{ + .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" }, + }; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 73, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:10000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + var n_new_stock: usize = 0; + var n_lot_removed: usize = 0; + var n_unmatched: usize = 0; + for (report.changes) |c| switch (c.kind) { + .new_stock => n_new_stock += 1, + .lot_removed => n_lot_removed += 1, + .unmatched_transfer => n_unmatched += 1, + else => {}, + }; + // Base classifications survive; the bad record is surfaced. try std.testing.expectEqual(@as(usize, 1), n_new_stock); + try std.testing.expectEqual(@as(usize, 1), n_lot_removed); try std.testing.expectEqual(@as(usize, 1), n_unmatched); } +test "matchInKindTransfer: cash destination is rejected as unmatched" { + // An in-kind record must name a SYMBOL@DATE lot. `dest_lot::cash` + // is nonsensical for a securities transfer. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{}; + const after = [_]Lot{ + .{ .symbol = "cash", .shares = 8000, .open_date = Date.fromYmd(2026, 5, 2), .open_price = 1.0, .security_type = .cash, .account = "Acct B" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::cash + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + var n_unmatched: usize = 0; + var unmatched_note: ?[]const u8 = null; + for (report.changes) |c| { + if (c.kind == .unmatched_transfer) { + n_unmatched += 1; + unmatched_note = c.transfer_note; + } + } + try std.testing.expectEqual(@as(usize, 1), n_unmatched); + try std.testing.expect(unmatched_note != null); + try std.testing.expect(std.mem.indexOf(u8, unmatched_note.?, "not cash") != null); +} + +test "matchInKindTransfer: destination lot not found emits unmatched" { + // The record names SYM but no SYM lot appeared on the `to` + // account (typo, or the lot didn't materialize this diff). The + // base diff is left untouched and the record 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(); + try prices.put("SYM", 100.0); + + const before = [_]Lot{}; + const after = [_]Lot{ + // A SYM lot landed on Acct C, not the record's `to` (Acct B). + .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct C" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + var n_new_stock: usize = 0; + var n_unmatched: usize = 0; + var unmatched_note: ?[]const u8 = null; + for (report.changes) |c| switch (c.kind) { + .new_stock => n_new_stock += 1, + .unmatched_transfer => { + n_unmatched += 1; + unmatched_note = c.transfer_note; + }, + else => {}, + }; + try std.testing.expectEqual(@as(usize, 1), n_new_stock); // Acct C lot untouched + try std.testing.expectEqual(@as(usize, 1), n_unmatched); + try std.testing.expect(unmatched_note != null); + try std.testing.expect(std.mem.indexOf(u8, unmatched_note.?, "not found") != null); +} + +test "matchInKindTransfer: duplicate record - first matches, second unmatched" { + // Two in-kind records name the same destination lot but the diff + // only shows one share-addition. The first claims it (flipped to + // transfer_in); the second can't re-match the now-reclassified + // Change and 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(); + try prices.put("SYM", 100.0); + + const before = [_]Lot{ + .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Acct A" }, + }; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct B" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 + \\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + 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 "printReport: in-kind transfer renders in Transfers section, out of totals" { + // End-to-end through the display layer: a report mixing a real + // new contribution, an in-kind transfer (in + out), a cash delta, + // and an unmatched removal should render every section and keep + // the transferred securities out of the grand total. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SYM", 100.0); + try prices.put("NEWX", 200.0); + + const before = [_]Lot{ + .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100, .account = "Sample IRA" }, + .{ .symbol = "OLDX", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50, .account = "Sample HSA" }, + .{ .symbol = "cash", .shares = 1000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 80, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Sample Roth IRA" }, + .{ .symbol = "NEWX", .shares = 5, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 200, .account = "Sample Brokerage" }, + .{ .symbol = "cash", .shares = 1500, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:8000,from::Sample IRA,to::Sample Roth IRA,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + var aw: std.Io.Writer.Allocating = .init(allocator); + const out = &aw.writer; + try printReport(out, &report, "test window", false); + const text = aw.written(); + + // Sections present. + try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Transfers (matched - not counted)") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Cash deltas") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Flagged for review") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Summary by account") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Grand total") != null); + + // The real new purchase and the unmatched removal show up. + try std.testing.expect(std.mem.indexOf(u8, text, "NEWX") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "OLDX") != null); + // The in-kind transfer's sending account is cross-referenced in + // the Transfers section. + try std.testing.expect(std.mem.indexOf(u8, text, "Sample IRA") != null); + + // Grand total = NEWX purchase ($1,000) only; the $8,000 in-kind + // SYM move and the $500 cash delta don't count as contributions. + try std.testing.expect(std.mem.indexOf(u8, text, "New contributions / purchases: $1,000.00") != null); +} + +test "printReport: no changes detected renders a single line" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{}; + const after = [_]Lot{}; + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + var aw: std.Io.Writer.Allocating = .init(allocator); + const out = &aw.writer; + try printReport(out, &report, "test window", false); + const text = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, text, "No changes detected") != null); +} + +test "printReport: color=true emits ANSI escapes" { + 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("NEWX", 200.0); + + const before = [_]Lot{}; + const after = [_]Lot{ + .{ .symbol = "NEWX", .shares = 5, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 200, .account = "Sample Brokerage" }, + }; + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + var aw: std.Io.Writer.Allocating = .init(allocator); + const out = &aw.writer; + try printReport(out, &report, "test window", true); + const text = aw.written(); + // ESC (0x1b) appears when color is on. + try std.testing.expect(std.mem.indexOfScalar(u8, text, 0x1b) != null); +} + test "matchTransfers: back-dated record matches regardless of date" { // The matcher itself is date-agnostic now - the caller (typically // `prepareReport` via `diffTransferLogs`) is responsible for diff --git a/src/models/transaction_log.zig b/src/models/transaction_log.zig index 1217b0b..6f52ea2 100644 --- a/src/models/transaction_log.zig +++ b/src/models/transaction_log.zig @@ -46,9 +46,11 @@ //! //! - Only `transfer::` records (no buys/sells/dividends - those stay //! inferred from the portfolio diff). -//! - Only `type::cash` is wired downstream. `type::in_kind` parses -//! successfully but is rejected by the contributions matcher with -//! an "in-kind transfers not yet supported" message. +//! - Both `type::cash` and `type::in_kind` are wired into the +//! contributions matcher. Cash records match against a cash budget / +//! pooled destination; in-kind records (securities moved without +//! cash changing hands) match a source share-removal against a +//! destination share-addition, per-symbol. //! - No historical reconstruction - forward-looking only. //! //! See `REPORT.md` §5 for the full usage guide and @@ -61,10 +63,11 @@ const Date = @import("../Date.zig"); const logger = std.log.scoped(.transaction_log); -/// Kind of transfer. Only `cash` is wired into the contributions -/// classifier in v1. `in_kind` parses successfully so the file format -/// is forward-compatible, but the matcher will reject records with -/// this type until per-symbol in-kind matching is implemented. +/// Kind of transfer. Both kinds are wired into the contributions +/// classifier: `cash` matches against the destination account's cash +/// budget / pooled cash activity, while `in_kind` pairs a source +/// share-removal against a destination share-addition for the same +/// symbol across the `from` and `to` accounts. pub const TransferType = enum { cash, in_kind, @@ -626,7 +629,7 @@ test "parseTransactionLogFile: type defaults to cash when elided" { try testing.expectEqual(TransferType.cash, log.transfers[0].type); } -test "parseTransactionLogFile: type::in_kind parses but is preserved (rejected downstream)" { +test "parseTransactionLogFile: type::in_kind parses and preserves its type" { var log = try parseTransactionLogFile(testing.allocator, \\#!srfv1 \\transfer::2026-05-02,type::in_kind,amount:num:50000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03