surface suspected account cash transfers through audit command
This commit is contained in:
parent
7729ac8d7e
commit
88de7a9882
2 changed files with 437 additions and 2 deletions
|
|
@ -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::<SOURCE>` 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::<SOURCE>,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::<SOURCE>,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::<SOURCE>,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::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue