From 88de7a98827776fff9f2f5c725e7699eec98f0f4 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 7 May 2026 14:32:36 -0700 Subject: [PATCH] surface suspected account cash transfers through audit command --- src/commands/audit.zig | 146 ++++++++++++++++ src/commands/contributions.zig | 293 ++++++++++++++++++++++++++++++++- 2 files changed, 437 insertions(+), 2 deletions(-) diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 21db9b5..224e5e3 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -4,6 +4,7 @@ const cli = @import("common.zig"); const fmt = cli.fmt; const analysis = @import("../analytics/analysis.zig"); const portfolio_mod = @import("../models/portfolio.zig"); +const contributions = @import("contributions.zig"); // ── Brokerage position (normalized from any source) ───────── @@ -1446,6 +1447,20 @@ const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only const default_stale_days: u32 = 3; const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold +/// Dollar threshold above which a new lot (new_stock / new_drip_lot / +/// new_cash / new_cd / cash_contribution) gets flagged in the +/// "Large new lots — confirm source" hygiene section. Below this +/// threshold new lots pass silently — the audit's goal is to catch +/// unconfirmed six-figure movements, not flag every payroll +/// contribution. +/// +/// $10k is a judgment call: high enough to ignore routine payroll +/// ESPP accruals and $1-$2k weekly deposits, low enough to surface +/// a typical IRA contribution or a genuine transfer. Tunable here, +/// per the plan's "revisit if the threshold proves wrong" note in +/// TODO.md. +const audit_large_lot_threshold: f64 = 10_000.0; + /// Type of a discovered brokerage file. const BrokerFileKind = enum { fidelity_csv, @@ -1651,6 +1666,68 @@ fn stalenessColor(age_days: i32, threshold: u32) [3]u8 { return cli.CLR_NEGATIVE; } +/// Render one unmatched large-lot warning. Formats the line the +/// user needs to paste into `transaction_log.srf` if the lot was +/// an internal movement rather than a real external contribution. +/// Leaves `from::` as a placeholder — the audit doesn't +/// know which account the money came from. +/// +/// 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. +fn printLargeLotWarning( + out: *std.Io.Writer, + lot: contributions.UnmatchedLargeLot, + color: bool, +) !void { + var val_buf: [32]u8 = undefined; + var date_buf: [10]u8 = undefined; + const value_str = fmt.fmtMoneyAbs(&val_buf, lot.value); + const date_str = lot.open_date.format(&date_buf); + const kind_label: []const u8 = switch (lot.security_type) { + .stock => "STOCK", + .cash => "CASH", + .cd => "CD", + .option => "OPTION", + else => "LOT", + }; + + const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash"; + try out.print( + " {s}: new {s} lot {s} ", + .{ lot.account, kind_label, sym_for_display }, + ); + try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str}); + try out.print(" on {s}\n", .{date_str}); + + try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{}); + + // Amount formatted as a whole-dollar number for the `num:` + // encoding; precise-to-the-cent values are rare in practice + // and callers can edit the template if needed. + const amount_int: i64 = @intFromFloat(@round(lot.value)); + + if (lot.security_type == .cash) { + try cli.printFg( + out, + color, + cli.CLR_MUTED, + " transfer::{s},type::cash,amount:num:{d},from::,to::{s},dest_lot::cash\n", + .{ date_str, amount_int, lot.account }, + ); + } else { + try cli.printFg( + out, + color, + cli.CLR_MUTED, + " transfer::{s},type::cash,amount:num:{d},from::,to::{s},dest_lot::{s}@{s}\n", + .{ date_str, amount_int, lot.account, lot.symbol, date_str }, + ); + } +} + /// Run the flagless portfolio hygiene check. fn runHygieneCheck( allocator: std.mem.Allocator, @@ -2044,6 +2121,31 @@ fn runHygieneCheck( } } + // ── Section 5: Large new lots — confirm source ── + // + // Cross-check any new_* Change with value >= threshold against + // `transaction_log.srf` (via the shared contributions pipeline). + // Surfaces lots that look like significant external contributions + // OR unrecorded internal transfers — nudges the user to either + // confirm or add a transfer record. + // + // Silent when every large lot matched a transfer record, when + // there are no new lots at all, or when the pipeline can't run + // (not in a git repo). Threshold is a judgment call; see + // `audit_large_lot_threshold`. + if (contributions.findUnmatchedLargeLots(allocator, svc, portfolio_path, audit_large_lot_threshold, color)) |found| { + var found_mut = found; + defer found_mut.deinit(); + + if (found_mut.lots.len > 0) { + try out.print("\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots — confirm source\n", .{}); + for (found_mut.lots) |lot| { + try printLargeLotWarning(out, lot, color); + } + } + } + try out.print("\n", .{}); } @@ -3003,3 +3105,47 @@ test "discoverBrokerFiles: nonexistent directory returns empty" { try std.testing.expectEqual(@as(usize, 0), files.len); } + +test "printLargeLotWarning: cash destination emits dest_lot::cash template" { + var buf: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + + const lot: contributions.UnmatchedLargeLot = .{ + .account = "Acct A", + .symbol = "", + .security_type = .cash, + .value = 50_000.0, + .open_date = @import("../models/date.zig").Date.fromYmd(2026, 5, 10), + }; + + try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes + const output = writer.buffered(); + + // Header line with account + value + date. + try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null); + + // Template line with the expected SRF shape. + try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000,from::,to::Acct A,dest_lot::cash") != null); +} + +test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" { + var buf: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + + const lot: contributions.UnmatchedLargeLot = .{ + .account = "Acct B", + .symbol = "SYM", + .security_type = .stock, + .value = 25_000.0, + .open_date = @import("../models/date.zig").Date.fromYmd(2026, 5, 3), + }; + + try printLargeLotWarning(&writer, lot, false); + const output = writer.buffered(); + + try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000,from::,to::Acct B,dest_lot::SYM@2026-05-03") != null); +} diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 34978a7..0ba2e76 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -693,6 +693,127 @@ pub fn computeAttributionSpec( return summarizeAttribution(ctx); } +// ── Public audit hook ──────────────────────────────────────── + +/// Descriptor of a "large new lot" the audit command may want to +/// surface. Emitted by `findUnmatchedLargeLots` for any new-side +/// Change whose `value()` meets the caller's threshold and which +/// was NOT reclassified by the transfer-log matcher. All string +/// fields are caller-arena-owned through the `UnmatchedLargeLotSet` +/// wrapper; the caller frees everything at once via `deinit`. +pub const UnmatchedLargeLot = struct { + /// The account the lot landed on (arena-owned copy). + account: []const u8, + /// Security ticker, or empty string for cash-kind lots. + symbol: []const u8, + /// `.stock`, `.cash`, `.cd`, etc. Drives the dest_lot shape in + /// the suggested template. + security_type: LotType, + /// Dollar value of the lot (`Change.value()`). + value: f64, + /// Lot open_date. Used in the `dest_lot::SYMBOL@OPEN_DATE` + /// template for stock / CD destinations. + open_date: Date, +}; + +/// Result wrapper that owns both the slice and the arena the string +/// fields live in. Caller must `deinit`. +pub const UnmatchedLargeLotSet = struct { + lots: []UnmatchedLargeLot, + arena: std.heap.ArenaAllocator, + + pub fn deinit(self: *UnmatchedLargeLotSet) void { + self.arena.deinit(); + } +}; + +/// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd +/// / cash_contribution) with `value() >= threshold` that weren't +/// matched to a record in `transaction_log.srf` over the HEAD → +/// working-copy window. Mirrors the `zfin contributions` zero-flag +/// path — uses `prepareReport`'s shared git + portfolio + transfer +/// plumbing so the classification is identical. Returns null if the +/// pipeline can't resolve a window (not in a git repo, etc.). +/// +/// Consumed by `zfin audit` to prompt the user to either confirm +/// the lot as an external contribution or add a transfer record +/// when it was really an internal movement. Works whether or not +/// `transaction_log.srf` exists — when absent, every large lot +/// surfaces since nothing gets reclassified. +pub fn findUnmatchedLargeLots( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + portfolio_path: []const u8, + threshold: f64, + color: bool, +) ?UnmatchedLargeLotSet { + var arena_state = std.heap.ArenaAllocator.init(allocator); + errdefer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Explicit null/null selects the legacy zero-flag path: + // before=HEAD~1..HEAD if clean, HEAD..working-copy if dirty. + // Audit cares about the dirty-working-copy case in practice + // (that's where "unconfirmed large lots" live), but both + // branches are valid consumers. + // + // Separate allocator here so we can tear the whole thing down + // via `arena_state.deinit` once we've copied out the descriptors. + var ctx = prepareReport(allocator, arena, svc, portfolio_path, null, null, color, .silent) catch { + arena_state.deinit(); + return null; + }; + defer ctx.deinit(); + + const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, threshold) catch { + arena_state.deinit(); + return null; + }; + return .{ .lots = lots, .arena = arena_state }; +} + +/// Pure filter: pick out new-side Changes with `value() >= threshold` +/// and dupe their string fields into `arena`. Split out so tests can +/// feed a synthetic `[]Change` without running the git/IO pipeline. +/// +/// Deliberately excludes `partial_transfer_in`. A partial lot already +/// has an explicit transfer record acknowledging the large movement; +/// the unmatched residual is typically small (pre-existing cash that +/// topped the lot off) and surfacing it again would nag on something +/// the user has already documented. If a residual is large enough to +/// care about independently, the user can review the lot's full value +/// via `zfin contributions` — this filter's job is to catch +/// *unrecorded* large movements, not to re-flag partial ones. +fn collectUnmatchedLargeLots( + arena: std.mem.Allocator, + changes: []const Change, + threshold: f64, +) ![]UnmatchedLargeLot { + var out: std.ArrayList(UnmatchedLargeLot) = .empty; + for (changes) |c| { + const is_new_side = switch (c.kind) { + .new_stock, .new_drip_lot, .new_cash, .new_cd, .cash_contribution => true, + else => false, + }; + if (!is_new_side) continue; + if (c.value() < threshold) continue; + + // open_date is populated in Pass 1's new-lot branch; absence + // here would be a pipeline bug. + const od = c.open_date orelse return error.MissingOpenDate; + const account_copy = try arena.dupe(u8, c.account); + const symbol_copy = try arena.dupe(u8, c.symbol); + try out.append(arena, .{ + .account = account_copy, + .symbol = symbol_copy, + .security_type = c.security_type, + .value = c.value(), + .open_date = od, + }); + } + return out.toOwnedSlice(arena); +} + fn summarizeAttribution(ctx: ReportContext) AttributionSummary { // Aggregate. Classification logic matches the full-report sections: @@ -822,6 +943,11 @@ const Change = struct { new_price: f64 = 0, /// Free-form detail for flagged changes. detail: ?[]const u8 = null, + /// Lot open_date for new_* kinds — carried here so downstream + /// consumers (the audit large-lot warning, the Transfers section + /// printer) can generate `transfer_log.srf` templates without + /// re-reading the after-portfolio. Null for non-new kinds. + open_date: ?Date = null, // ── Transfer reclassification (see `matchTransfers`) ──── /// Dollar amount of this Change's `value()` that's attributable @@ -1348,6 +1474,7 @@ fn computeReport( .security_type = lot.security_type, .delta_shares = after_agg.shares, .unit_value = unit_value, + .open_date = lot.open_date, }); } } @@ -3352,7 +3479,7 @@ test "short: short input returned as-is" { try std.testing.expectEqualStrings("abc", short("abc")); } -// ── matchTransfers tests (M2) ──────────────────────────────── +// ── matchTransfers tests ───────────────────────────────────── // // These exercise the transfer reclassification pipeline end-to-end // via `computeReport(... .{ .transfer_log = &log, ... })`. Each test @@ -3788,7 +3915,7 @@ test "matchTransfers: null transfer_log is a no-op (backward compat)" { .{ .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. + // No transfer_log passed — baseline behavior with no reclassification. const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); try std.testing.expectEqual(@as(usize, 1), report.changes.len); @@ -3838,3 +3965,165 @@ test "matchTransfers: attribution excludes transferred amount" { try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.0), drip, 0.01); } + +// ── collectUnmatchedLargeLots tests ────────────────────────── +// +// These exercise the audit large-lot filter by building a `Report` +// via `computeReport` (with or without a transfer log) and feeding +// its changes directly to `collectUnmatchedLargeLots`. That skips +// the git + IO plumbing of `findUnmatchedLargeLots` while still +// covering the classification path the production code uses. + +test "collectUnmatchedLargeLots: below threshold is silent" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SYM", 100.0); + + const before = [_]Lot{}; + // $5k lot — under the $10k threshold used by audit. + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 50, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 100, .account = "Acct A" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + + try std.testing.expectEqual(@as(usize, 0), lots.len); +} + +test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SYM", 500.0); + + const before = [_]Lot{}; + // $50k stock lot, no transfer log, should surface. + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + + try std.testing.expectEqual(@as(usize, 1), lots.len); + try std.testing.expectEqualStrings("Acct A", lots[0].account); + try std.testing.expectEqualStrings("SYM", lots[0].symbol); + try std.testing.expectEqual(LotType.stock, lots[0].security_type); + try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01); + try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days); +} + +test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{}; + const after = [_]Lot{ + .{ .symbol = "cash", .shares = 50_000, .open_date = Date.fromYmd(2026, 5, 10), .open_price = 1.0, .security_type = .cash, .account = "Acct A" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{}); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + + try std.testing.expectEqual(@as(usize, 1), lots.len); + try std.testing.expectEqual(LotType.cash, lots[0].security_type); + try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), lots[0].value, 0.01); +} + +test "collectUnmatchedLargeLots: matched via transfer log is silent" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SYM", 500.0); + + const before = [_]Lot{}; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" }, + }; + + // Transfer log fully covers the lot → kind flips to transfer_in. + var tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::cash,amount:num:50000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = &tlog, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + // Sanity: the lot should have been reclassified. + try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind); + + const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + try std.testing.expectEqual(@as(usize, 0), lots.len); +} + +test "collectUnmatchedLargeLots: no new lots → empty result" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{}; + const after = [_]Lot{}; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + try std.testing.expectEqual(@as(usize, 0), lots.len); +} + +test "collectUnmatchedLargeLots: partial transfer still flags residual? No — full lot value counts" { + // Partial transfers leave the Change as `partial_transfer_in`, + // which the audit filter IGNORES (only new_* kinds pass the + // `is_new_side` check). That's the correct behavior: the + // unrecorded portion on a partial is typically small (pre- + // existing cash) and already has an explicit transfer record + // acknowledging the large movement. Surfacing it again would + // double-nag. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SYM", 500.0); + + const before = [_]Lot{}; + // $50k lot, $45k from a transfer → partial_transfer_in with + // $5k residual. Residual is below $10k threshold anyway, but + // even if it weren't, the filter skips partial_transfer_in. + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Acct A" }, + }; + + var tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::cash,amount:num:45000,from::Acct B,to::Acct A,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = &tlog, + .window_start = Date.fromYmd(2026, 1, 1), + .window_end = Date.fromYmd(2026, 12, 31), + }); + + try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind); + + const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0); + try std.testing.expectEqual(@as(usize, 0), lots.len); +}