From 0882e6321f6efa66442da63a12045d4103276dfb Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 27 Jun 2026 11:40:58 -0700 Subject: [PATCH] contributions to consider shares purchased with cash in account --- src/commands/contributions.zig | 525 ++++++++++++++++++++++++++++++++- 1 file changed, 519 insertions(+), 6 deletions(-) diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 5a8fdc7..a1f65af 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -46,6 +46,34 @@ //! - `price_only` - same-key, only the `price::` field changed //! - `flagged` - any other edit shape (maturity_date change, etc.) //! +//! ### Intra-account purchase netting (`matchIntraAccountPurchases`) +//! +//! A plain buy made with cash already in the account is not a fresh +//! contribution - the cash was counted when it arrived, and investing +//! it just changes its form. In the diff such a buy is two changes on +//! the SAME account: a `new_stock` / `new_cd` lot appearing, and the +//! account's cash going down (a negative `cash_delta`, or a cash +//! `lot_removed` if the line was fully spent). `matchIntraAccountPurchases` +//! builds a per-account cash-outflow budget from those decreases and +//! draws it down against the account's `new_stock` / `new_cd` lots, +//! recording the funded amount on each Change's `internal_funded`. +//! `attributedValue()` subtracts it, so the funded portion leaves +//! attribution everywhere at once (report, per-account totals, compare, +//! audit large-lot nudge) - no `transaction_log.srf` entry required. +//! +//! | Scenario | Kind | Section | In Grand Total | In Attribution | +//! |------------------------------------------------|--------------|------------------------|:--------------:|:--------------:| +//! | Buy fully funded by same-account cash decrease | `new_stock` | Internal purchases | no | no | +//! | Buy partly funded (cash + new money) | `new_stock` | New contributions (residual) + Internal purchases | residual | residual | +//! +//! Runs AFTER the transfer matcher so explicit `transaction_log.srf` +//! records win: cash already credited to a `transfer_out` isn't in the +//! budget, and a lot already flipped to `transfer_in` is no longer +//! `new_stock` / `new_cd`. Scope is deliberately narrow - only brand-new +//! `new_stock` / `new_cd` lots, same account. `new_drip_lot`, +//! `rollup_delta` / `drip_confirmed`, and `partial_transfer_in` +//! residuals are left untouched (see `matchIntraAccountPurchases`). +//! //! ### Cash-account opt-in (`cash_is_contribution::true` in accounts.srf) //! //! Most cash-account activity is internal flow - DRIP cash legs, @@ -1211,6 +1239,24 @@ const Change = struct { /// kinds. transfer_date: ?Date = null, + // ── Intra-account purchase netting (see `matchIntraAccountPurchases`) ──── + /// Portion of this Change's `value()` funded by a decrease in the + /// SAME account's cash during the window - i.e. a plain buy of a + /// new lot using cash that was already sitting in the account + /// (cash -> security). Set only for `new_stock` / `new_cd`. The + /// matched cash leaves as a negative `cash_delta` (or a removed + /// cash lot) on the same account, so funding the purchase with it + /// is internal movement, not a fresh contribution. `attributedValue()` + /// subtracts it; a fully-funded buy nets to $0 and drops out of + /// "New contributions", the per-account totals, the compare + /// attribution line, and the audit large-lot nudge. Zero otherwise. + /// + /// Distinct from `transfer_attributed` (which tracks cross-account + /// movement declared in `transaction_log.srf`); the two are + /// additive on `new_stock` / `new_cd` and never overlap because a + /// transfer-matched lot is reclassified away from those kinds. + internal_funded: f64 = 0, + pub fn value(self: Change) f64 { return self.delta_shares * self.unit_value; } @@ -1222,15 +1268,19 @@ const Change = struct { /// `new_cash`, `cash_contribution`, and `cash_delta`, the /// matcher may have credited some of the value to a cash- /// destination transfer record (see `matchCashDestination`); - /// `transfer_attributed` tracks how much. The residual is - /// `value() - transfer_attributed`, which is what shows up in - /// "New contributions / purchases" and the audit large-lot - /// filter. Everyone else sees `value()` unchanged. + /// `transfer_attributed` tracks how much. For `new_stock` / + /// `new_cd`, `internal_funded` tracks how much of the purchase was + /// funded by a same-account cash decrease (see + /// `matchIntraAccountPurchases`). The residual is + /// `value() - transfer_attributed - internal_funded`, which is + /// what shows up in "New contributions / purchases" and the audit + /// large-lot filter. Everyone else sees `value()` unchanged. pub fn attributedValue(self: Change) f64 { return switch (self.kind) { .transfer_in, .transfer_out, .unmatched_transfer => 0, .partial_transfer_in => self.value() - self.transfer_attributed, .new_cash, .cash_contribution, .cash_delta => self.value() - self.transfer_attributed, + .new_stock, .new_cd => self.value() - self.transfer_attributed - self.internal_funded, else => self.value(), }; } @@ -1800,6 +1850,16 @@ fn computeReport( ); } + // Intra-account purchase netting: a decrease in an account's cash + // (negative cash_delta or a removed cash lot) is presumed to fund + // new_stock / new_cd lots that appeared in the SAME account - a + // plain buy of existing cash, not a fresh contribution. Runs AFTER + // matchTransfers so explicit transfer records win (cash already + // claimed as a transfer_out doesn't double-fund a purchase, and a + // lot already reclassified to transfer_in is no longer new_stock / + // new_cd). See `matchIntraAccountPurchases`. + try matchIntraAccountPurchases(allocator, &changes); + // Build per-account totals. var acct_totals = std.StringHashMap(Report.AccountTotal).init(allocator); @@ -2440,6 +2500,91 @@ fn matchInKindTransfer( } } +// ── Intra-account purchase netting ─────────────────────────── + +/// Net same-account cash decreases against new purchase lots. +/// +/// A plain buy made with cash already in the account shows up in the +/// diff as two changes on the SAME account: a `new_stock` / `new_cd` +/// lot appearing, and the account's cash going down (a negative +/// `cash_delta`, or a `lot_removed` if the cash line was fully +/// consumed). No new money entered the portfolio - the cash was +/// already counted when it arrived - so the purchase shouldn't read +/// as a fresh contribution. +/// +/// This pass builds a per-account "cash outflow" budget from those +/// decreases and draws it down against the account's `new_stock` / +/// `new_cd` changes, accumulating the funded amount onto each Change's +/// `internal_funded`. `attributedValue()` subtracts it, so a fully- +/// funded buy nets to $0 (drops out of "New contributions", per-account +/// totals, the compare attribution line, and the audit large-lot +/// nudge) while a partially-funded buy keeps the unfunded residual as +/// real new money. +/// +/// Runs after `matchTransfers`, so: +/// - Cash already reclassified to `transfer_out` (an outflow to a +/// declared transfer) is NOT in the budget - it can't also fund an +/// intra-account purchase. +/// - Lots already reclassified to `transfer_in` / `partial_transfer_in` +/// are no longer `new_stock` / `new_cd`, so an explicit transfer +/// record always takes priority over this automatic netting. +/// +/// Scope is deliberately narrow - only brand-new `new_stock` / `new_cd` +/// lots. `new_drip_lot` (a reinvested dividend, not a cash buy), +/// `rollup_delta` / `drip_confirmed` (share adds to an existing lot), +/// and `partial_transfer_in` residuals are left untouched. Same-account +/// only; cross-account movement stays `transaction_log.srf`'s job. +/// +/// Budget is drawn in change-iteration order when an account has +/// several purchase lots; the total netted is the same regardless of +/// order, but which specific lot shows a residual can vary. This +/// mirrors `matchCashDestination`'s order-dependent draw. +fn matchIntraAccountPurchases( + allocator: std.mem.Allocator, + changes: *std.ArrayList(Change), +) !void { + // Per-account cash outflow: magnitude of net cash that left the + // account's cash pool this window. Negative cash_delta is a partial + // spend; a removed cash lot is a fully-drained line (its dollar + // amount lives in `face_value`, since `value()` is 0 for removals). + var outflow: std.StringHashMap(f64) = .init(allocator); + defer outflow.deinit(); + for (changes.items) |c| { + switch (c.kind) { + .cash_delta => { + const v = c.value(); + if (v < 0) { + const gop = try outflow.getOrPut(c.account); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += -v; + } + }, + .lot_removed => if (c.security_type == .cash) { + const gop = try outflow.getOrPut(c.account); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += c.face_value; + }, + else => {}, + } + } + if (outflow.count() == 0) return; + + // Draw each account's outflow down against its new purchase lots. + for (changes.items) |*c| { + switch (c.kind) { + .new_stock, .new_cd => {}, + else => continue, + } + const budget = outflow.getPtr(c.account) orelse continue; + if (budget.* <= 0) continue; + const unattributed = c.attributedValue(); // value() minus any prior attribution + if (unattributed <= 0) continue; + const draw = @min(unattributed, budget.*); + c.internal_funded += draw; + budget.* -= draw; + } +} + // ── Output ─────────────────────────────────────────────────── fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, color: bool) !void { @@ -2472,12 +2617,18 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co // Use attributedValue so a cash lot fully covered by a // transfer record (whose `transfer_attributed` equals // its `value()`) drops out, and a partially-covered - // lot shows only the unattributed remainder. + // lot shows only the unattributed remainder. The same + // residual logic covers `new_stock` / `new_cd` partially + // funded by a same-account cash decrease (internal_funded). const residual = c.attributedValue(); if (residual <= 0) continue; any = true; new_total += residual; - try printChangeLine(out, c, color, pos_color); + if (c.internal_funded > 0) { + try printCashFundedResidualLine(out, c, color, pos_color, mut_color); + } else { + try printChangeLine(out, c, color, pos_color); + } }, .partial_transfer_in => { // Lot partially funded by a transfer: show the residual @@ -2572,6 +2723,31 @@ 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: Internal purchases (cash-funded - not counted) ── + // + // new_stock / new_cd lots whose value was funded (wholly or in + // part) by a decrease in the SAME account's cash this window - a + // plain buy of existing cash, reclassified by + // `matchIntraAccountPurchases`. The funded portion is internal + // movement, not a fresh contribution, so it's shown here (muted) + // rather than counted in "New contributions". A partially-funded + // lot also appears in "New contributions" on its unfunded residual. + var any_internal = false; + for (report.changes) |c| { + if (c.internal_funded > 0) { + any_internal = true; + break; + } + } + if (any_internal) { + try printSection(out, "Internal purchases (cash -> securities, not counted)", color, h_color); + for (report.changes) |c| { + if (c.internal_funded <= 0) continue; + try printInternalPurchaseLine(out, c, color, mut_color); + } + try out.writeAll("\n"); + } + // ── Section: Transfers (matched - not counted) ── // // Any record from `transaction_log.srf` that matched a @@ -2945,6 +3121,47 @@ fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3 ); } +/// Render a `new_stock` / `new_cd` row in the "New contributions" +/// section when the lot was PARTIALLY funded by a same-account cash +/// decrease. Shows the unfunded residual (real new money) plus an +/// annotation breaking out the cash-funded portion, mirroring +/// `printPartialTransferLine`'s shape for transfers. +fn printCashFundedResidualLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8, muted: [3]u8) !void { + const acct = if (c.account.len == 0) "(no account)" else c.account; + const residual = c.attributedValue(); + const lot_value = c.value(); + const sym = if (c.symbol.len > 0) c.symbol else "cash"; + + try out.print(" {s:<14}{s:<24}", .{ sym, acct }); + try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)}); + try cli.printFg( + out, + color, + muted, + " (of {f} total - {f} from existing cash)\n", + .{ Money.from(lot_value), Money.from(c.internal_funded) }, + ); +} + +/// Render an "Internal purchases" row: a `new_stock` / `new_cd` lot +/// funded (wholly or in part) by a same-account cash decrease. Muted - +/// these don't count toward attribution. Shows the funded amount and, +/// when only part of the lot was cash-funded, the full lot value so +/// the unfunded residual (shown in "New contributions") reconciles. +fn printInternalPurchaseLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { + const acct = if (c.account.len == 0) "(no account)" else c.account; + const sym = if (c.symbol.len > 0) c.symbol else "cash"; + const lot_value = c.value(); + + try cli.setFg(out, color, muted); + try out.print(" {s:<14}{s:<24} {f} from existing cash", .{ sym, acct, Money.from(c.internal_funded) }); + if (c.internal_funded + 0.005 < lot_value) { + try out.print(" (of {f} lot)", .{Money.from(lot_value)}); + } + try cli.reset(out, color); + try out.writeAll("\n"); +} + // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; @@ -3437,6 +3654,235 @@ test "computeReport: CD open_date rewrite reclassified as edit, not new+removed" try std.testing.expectApproxEqAbs(@as(f64, 0), drip, 0.01); } +// ── Intra-account purchase netting (matchIntraAccountPurchases) ── + +test "computeReport: stock bought with existing cash nets to zero contribution" { + // The user's scenario: $30k of cash already in the account is + // spent on a new stock lot. The diff shows new_stock +$30k plus a + // -$30k cash_delta on the SAME account. Netting funds the buy from + // the cash decrease, so it contributes nothing. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + .{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + .{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + var new_stock: ?Change = null; + var cash_delta: ?Change = null; + for (report.changes) |c| switch (c.kind) { + .new_stock => new_stock = c, + .cash_delta => cash_delta = c, + else => {}, + }; + try std.testing.expect(new_stock != null); + try std.testing.expect(cash_delta != null); + // Buy fully funded by the $30k cash decrease. + try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.value(), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.attributedValue(), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, -30_000.0), cash_delta.?.value(), 0.01); +} + +test "computeReport: stock bought with a fully-consumed cash lot (lot_removed) nets to zero" { + // Same as above but the cash line was spent in full and deleted, + // so the cash decrease surfaces as a removed cash lot rather than + // a negative cash_delta. The removed lot's dollar amount lives in + // face_value. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + var new_stock: ?Change = null; + var cash_removed = false; + for (report.changes) |c| switch (c.kind) { + .new_stock => new_stock = c, + .lot_removed => if (c.security_type == .cash) { + cash_removed = true; + }, + else => {}, + }; + try std.testing.expect(new_stock != null); + try std.testing.expect(cash_removed); + try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.attributedValue(), 0.01); +} + +test "computeReport: buy partly funded by cash, partly new money surfaces the residual" { + // $30k existing cash spent + $20k new money into a single $50k buy. + // Only the $20k unfunded residual counts as a contribution. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + // Cash line fully spent (removed); $50k stock lot appears. + .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + var new_stock: ?Change = null; + for (report.changes) |c| { + if (c.kind == .new_stock) new_stock = c; + } + try std.testing.expect(new_stock != null); + try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), new_stock.?.value(), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.internal_funded, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), new_stock.?.attributedValue(), 0.01); +} + +test "computeReport: cross-account cash decrease does NOT fund a purchase" { + // Cash drops in Acct A; a new stock lot appears in Acct B. These + // are different accounts, so netting does not apply - the buy + // still counts (cross-account movement is transaction_log's job). + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample IRA" }, + }; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + var new_stock: ?Change = null; + for (report.changes) |c| { + if (c.kind == .new_stock) new_stock = c; + } + try std.testing.expect(new_stock != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_stock.?.internal_funded, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), new_stock.?.attributedValue(), 0.01); +} + +test "computeReport: new-account deposit-and-invest still counts (no prior cash)" { + // Fresh account: cash deposited and partly invested in the same + // window. The cash never existed before, so there is no decrease + // to net against - both the new cash and the new stock are real + // contributions. + 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 = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + .{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + var new_money: f64 = 0; + for (report.changes) |c| switch (c.kind) { + .new_stock, .new_cash => { + new_money += c.attributedValue(); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), c.internal_funded, 0.01); + }, + else => {}, + }; + // $20k cash + $30k stock = $50k of genuinely new money. + try std.testing.expectApproxEqAbs(@as(f64, 50_000.0), new_money, 0.01); +} + +test "computeReport: existing-cash buy nets out of compare attribution too" { + // The classifier is the single source of truth, so the compare + // attribution line (summarizeAttribution) must agree with the + // report: a cash-funded buy contributes $0. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 40_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + .{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + .{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{}); + + // Replicate summarizeAttribution's new_contributions bucket. + var new_contributions: f64 = 0; + for (report.changes) |c| switch (c.kind) { + .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(), + .partial_transfer_in => new_contributions += c.attributedValue(), + else => {}, + }; + try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01); +} + +test "computeReport: transfer record takes priority over intra-account netting" { + // A buy declared as a cross-account transfer (transaction_log) + // flips to transfer_in BEFORE intra-account netting runs, so it + // must not also be credited with internal_funded even if the + // destination account happens to show a cash decrease. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + .{ .symbol = "", .shares = 10_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + .{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + const tlog = try transaction_log.parseTransactionLogFile(allocator, + \\#!srfv1 + \\transfer::2026-05-03,type::cash,amount:num:30000,from::Sample Source,to::Sample Brokerage,dest_lot::SYM@2026-05-03 + \\ + ); + + const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{ + .transfer_log = tlog.transfers, + }); + + for (report.changes) |c| { + if (std.mem.eql(u8, c.symbol, "SYM")) { + try std.testing.expectEqual(ChangeKind.transfer_in, c.kind); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), c.internal_funded, 0.01); + } + } +} + test "computeReport: stock open_price renormalized reclassified as edit" { // Reconciliation tweak: user updates `open_price` to match the // institutional-share-class NAV, leaving everything else alone. @@ -5443,6 +5889,73 @@ test "collectUnmatchedLargeLots: no new lots -> empty result" { try std.testing.expectEqual(@as(usize, 0), lots.len); } +test "collectUnmatchedLargeLots: buy funded by same-account cash is silent" { + // The user's audit complaint: a $30k stock lot bought with cash + // already in the account. The -$30k cash_delta funds it, netting + // its attributedValue to $0, so audit must not flag it - no + // transaction_log.srf entry required. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 50_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + .{ .symbol = "", .shares = 20_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + .{ .symbol = "SYM", .shares = 60, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + 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: partly cash-funded buy surfaces residual only" { + // $50k buy, $30k from existing cash -> $20k of new money remains, + // which is above the $10k threshold and SHOULD still surface. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + 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.expectApproxEqAbs(@as(f64, 20_000.0), lots[0].value, 0.01); +} + +test "collectUnmatchedLargeLots: partial cash-funded residual below threshold is silent" { + // $35k buy, $30k from existing cash -> $5k residual, below the + // $10k threshold -> silent. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const before = [_]Lot{ + .{ .symbol = "", .shares = 30_000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" }, + }; + const after = [_]Lot{ + .{ .symbol = "SYM", .shares = 70, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 500, .account = "Sample Brokerage" }, + }; + + 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