aggregate "missing accounts" check for flagless audit
This commit is contained in:
parent
0882e6321f
commit
378c0d0e84
3 changed files with 130 additions and 26 deletions
|
|
@ -205,7 +205,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer allocator.free(present);
|
||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
|
||||
defer allocator.free(absent);
|
||||
try common.displayAbsentAccounts(absent, color, out);
|
||||
try common.displayAbsentAccounts(absent, color, "this export", out);
|
||||
}
|
||||
|
||||
// Fidelity CSV
|
||||
|
|
@ -237,7 +237,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer allocator.free(present);
|
||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
|
||||
defer allocator.free(absent);
|
||||
try common.displayAbsentAccounts(absent, color, out);
|
||||
try common.displayAbsentAccounts(absent, color, "this export", out);
|
||||
}
|
||||
|
||||
// Schwab per-account CSV
|
||||
|
|
@ -269,7 +269,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer allocator.free(present);
|
||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
|
||||
defer allocator.free(absent);
|
||||
try common.displayAbsentAccounts(absent, color, out);
|
||||
try common.displayAbsentAccounts(absent, color, "this export", out);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -863,14 +863,19 @@ pub fn findAbsentAccounts(
|
|||
return results.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Render the "portfolio accounts not found in this export" advisory.
|
||||
/// Render the "portfolio accounts not found in <scope>" advisory.
|
||||
/// Silent when `absent` is empty, so it composes cleanly after both
|
||||
/// the verbose audit table and the compact "no discrepancies" line.
|
||||
pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, out: *std.Io.Writer) !void {
|
||||
///
|
||||
/// `scope_phrase` names what the absence is relative to: the explicit
|
||||
/// single-file command passes "this export"; the flagless audit, which
|
||||
/// unions the present accounts across every discovered file before
|
||||
/// computing absence, passes "any export".
|
||||
pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, scope_phrase: []const u8, out: *std.Io.Writer) !void {
|
||||
if (absent.len == 0) return;
|
||||
|
||||
try out.print("\n", .{});
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in this export", .{});
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in {s}", .{scope_phrase});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " (stale, or not exported?)\n", .{});
|
||||
for (absent) |a| {
|
||||
try out.print(" ", .{});
|
||||
|
|
@ -1652,7 +1657,7 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
|
|||
// Empty -> no output.
|
||||
{
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
try displayAbsentAccounts(&.{}, false, &w);
|
||||
try displayAbsentAccounts(&.{}, false, "this export", &w);
|
||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||
}
|
||||
|
||||
|
|
@ -1662,11 +1667,21 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
|
|||
const absent = [_]AbsentAccount{
|
||||
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
|
||||
};
|
||||
try displayAbsentAccounts(&absent, false, &w);
|
||||
try displayAbsentAccounts(&absent, false, "this export", &w);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "not found in this export") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "$2,100") != null);
|
||||
}
|
||||
|
||||
// The scope phrase is caller-supplied (flagless audit passes "any export").
|
||||
{
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
const absent = [_]AbsentAccount{
|
||||
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
|
||||
};
|
||||
try displayAbsentAccounts(&absent, false, "any export", &w);
|
||||
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "not found in any export") != null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -499,6 +499,27 @@ fn printLargeLotWarning(
|
|||
}
|
||||
}
|
||||
|
||||
/// Append the account numbers present in `results` to `dst`, duping
|
||||
/// each string into `allocator` so it outlives the per-file buffer the
|
||||
/// reconciler's result slices borrow from. Used by the flagless audit
|
||||
/// to union the present accounts across every discovered export before
|
||||
/// computing the "accounts not found in any export" advisory once, so a
|
||||
/// single-account positions CSV no longer flags every sibling account
|
||||
/// as missing. `T` is the reconciler's comparison type
|
||||
/// (`common.AccountComparison` or `schwab.SchwabAccountComparison`) -
|
||||
/// both expose an `account_number` field, which `common.presentNumbers`
|
||||
/// collects.
|
||||
fn accumulatePresent(
|
||||
allocator: std.mem.Allocator,
|
||||
dst: *std.ArrayList([]const u8),
|
||||
comptime T: type,
|
||||
results: []const T,
|
||||
) !void {
|
||||
const present = try common.presentNumbers(allocator, T, results);
|
||||
defer allocator.free(present);
|
||||
for (present) |num| try dst.append(allocator, try allocator.dupe(u8, num));
|
||||
}
|
||||
|
||||
/// Run the flagless portfolio hygiene check.
|
||||
pub fn runHygieneCheck(
|
||||
io: std.Io,
|
||||
|
|
@ -860,6 +881,23 @@ pub fn runHygieneCheck(
|
|||
try out.print("\n", .{});
|
||||
try cli.printBold(out, color, " Reconciliation\n", .{});
|
||||
|
||||
// Present account numbers per institution, unioned across every
|
||||
// discovered file. The "accounts not found" advisory is computed
|
||||
// once from these unions AFTER the loop - not per file - so a
|
||||
// single-account positions CSV no longer flags every other
|
||||
// account in the institution as missing (it's present in a
|
||||
// sibling export or the summary). Strings are duped because the
|
||||
// borrowed account-number slices point into each file's
|
||||
// `file_data`, which is freed per loop iteration.
|
||||
var fidelity_present: std.ArrayList([]const u8) = .empty;
|
||||
var schwab_present: std.ArrayList([]const u8) = .empty;
|
||||
defer {
|
||||
for (fidelity_present.items) |s| allocator.free(s);
|
||||
fidelity_present.deinit(allocator);
|
||||
for (schwab_present.items) |s| allocator.free(s);
|
||||
schwab_present.deinit(allocator);
|
||||
}
|
||||
|
||||
for (all_files.items) |f| {
|
||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
|
||||
defer allocator.free(file_data);
|
||||
|
|
@ -869,11 +907,6 @@ pub fn runHygieneCheck(
|
|||
const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
|
||||
defer allocator.free(results);
|
||||
|
||||
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
|
||||
defer allocator.free(absent);
|
||||
|
||||
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try schwab.displaySchwabResults(results, color, out);
|
||||
|
|
@ -889,7 +922,8 @@ pub fn runHygieneCheck(
|
|||
// non-zero delta that still deserves a nudge.
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
try common.displayAbsentAccounts(absent, color, out);
|
||||
|
||||
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
|
||||
},
|
||||
.fidelity_csv => {
|
||||
const results = fidelity.reconcile(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
|
||||
|
|
@ -898,11 +932,6 @@ pub fn runHygieneCheck(
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
|
||||
defer allocator.free(absent);
|
||||
|
||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(results, color, out);
|
||||
|
|
@ -912,7 +941,8 @@ pub fn runHygieneCheck(
|
|||
// Always show ratio suggestions even in compact mode
|
||||
try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
try common.displayAbsentAccounts(absent, color, out);
|
||||
|
||||
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
|
||||
},
|
||||
.schwab_csv => {
|
||||
const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
|
||||
|
|
@ -921,11 +951,6 @@ pub fn runHygieneCheck(
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
|
||||
defer allocator.free(absent);
|
||||
|
||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(results, color, out);
|
||||
|
|
@ -934,10 +959,27 @@ pub fn runHygieneCheck(
|
|||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
|
||||
try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
try common.displayAbsentAccounts(absent, color, out);
|
||||
|
||||
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// One advisory per institution that contributed an export,
|
||||
// computed against the unioned present-set. "any export"
|
||||
// reflects that absence is now relative to ALL discovered files
|
||||
// (see `displayAbsentAccounts`), so an account covered by any
|
||||
// sibling CSV or the summary no longer surfaces here.
|
||||
if (fidelity_present.items.len > 0) {
|
||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", fidelity_present.items, prices, as_of);
|
||||
defer allocator.free(absent);
|
||||
try common.displayAbsentAccounts(absent, color, "any export", out);
|
||||
}
|
||||
if (schwab_present.items.len > 0) {
|
||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", schwab_present.items, prices, as_of);
|
||||
defer allocator.free(absent);
|
||||
try common.displayAbsentAccounts(absent, color, "any export", out);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section 5: Large new lots - confirm source ──
|
||||
|
|
@ -970,6 +1012,53 @@ pub fn runHygieneCheck(
|
|||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "accumulatePresent: unions account numbers across calls" {
|
||||
const allocator = std.testing.allocator;
|
||||
var dst: std.ArrayList([]const u8) = .empty;
|
||||
defer {
|
||||
for (dst.items) |s| allocator.free(s);
|
||||
dst.deinit(allocator);
|
||||
}
|
||||
|
||||
const batch1 = [_]common.AccountComparison{
|
||||
.{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
|
||||
};
|
||||
const batch2 = [_]common.AccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
|
||||
};
|
||||
|
||||
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch1);
|
||||
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch2);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), dst.items.len);
|
||||
try std.testing.expectEqualStrings("1234", dst.items[0]);
|
||||
try std.testing.expectEqualStrings("5678", dst.items[1]);
|
||||
}
|
||||
|
||||
test "accumulatePresent: result strings are owned copies (survive source free)" {
|
||||
// The whole point of duping: the borrowed account_number points into
|
||||
// a per-file buffer freed each loop iteration. Prove the accumulator
|
||||
// keeps its own copy by freeing the source out from under it.
|
||||
const allocator = std.testing.allocator;
|
||||
var dst: std.ArrayList([]const u8) = .empty;
|
||||
defer {
|
||||
for (dst.items) |s| allocator.free(s);
|
||||
dst.deinit(allocator);
|
||||
}
|
||||
|
||||
const num = try allocator.dupe(u8, "9012");
|
||||
{
|
||||
const batch = [_]common.AccountComparison{
|
||||
.{ .account_name = "X", .brokerage_name = "X", .account_number = num, .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
|
||||
};
|
||||
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch);
|
||||
}
|
||||
allocator.free(num); // source gone; dst must hold its own copy
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), dst.items.len);
|
||||
try std.testing.expectEqualStrings("9012", dst.items[0]);
|
||||
}
|
||||
|
||||
test "detectBrokerFileKind: fidelity csv" {
|
||||
const fidelity_header = "Account Number,Account Name,Symbol,Description";
|
||||
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue