From 5e50c4eb6f4d622e46e7ce824b148f50fe8880de Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 23 May 2026 11:25:39 -0700 Subject: [PATCH] tweak tests --- TODO.md | 111 ++++++++++++++++++++++++- src/Config.zig | 2 +- src/analytics/analysis.zig | 20 ++--- src/analytics/timeline.zig | 4 +- src/brokerage/schwab.zig | 48 +++++------ src/brokerage/wells_fargo.zig | 143 +++++++++++++++++---------------- src/commands/audit.zig | 16 ++-- src/commands/contributions.zig | 18 ++--- src/commands/framework.zig | 8 +- src/commands/import.zig | 112 ++++++++++++++------------ src/commands/snapshot.zig | 8 +- src/history.zig | 6 +- src/main.zig | 2 +- src/models/transaction_log.zig | 4 +- 14 files changed, 312 insertions(+), 190 deletions(-) diff --git a/TODO.md b/TODO.md index 90b6f97..330be93 100644 --- a/TODO.md +++ b/TODO.md @@ -627,7 +627,116 @@ Investigate by replacing the `print` with a manual `print` + `flush` to see if it's a buffer-not-flushed issue, or by serializing a known-good fixture in isolation. -## Low-priority items +## Analysis: umbrella-insurance exposure indicator — priority MEDIUM + +In the `analysis` command and TUI tab, surface how much of the +liquid portfolio is *exposed to lawsuit / creditor risk* and +therefore should be covered by umbrella insurance. The number +the user actually wants is "how much could be lost if I'm sued +and lose" — which is liquid assets minus the legally-shielded +buckets. + +### Shielding rules (US, broad strokes) + +- **401(k) and other ERISA-qualified employer plans**: shielded + by ERISA, federal-law-level protection. Effectively untouchable + in lawsuits. Always exclude from the umbrella-coverage figure. +- **IRAs (traditional + Roth)**: protection is **state-by-state**. + - Federal bankruptcy protection up to ~$1.5M (BAPCPA, indexed) + applies in bankruptcy court only. + - Outside bankruptcy (i.e., civil judgments), it's state law. + Some states give full protection (e.g., TX, FL); some give + none; many give partial / "reasonably necessary for support." +- **Inherited IRAs**: a separate category. Less protected than + contributory IRAs in many states post-*Clark v. Rameker* (2014). + Ideally tracked separately if relevant. +- **HSAs**: typically protected if held in an HSA-qualified + account; varies by state. +- **Pensions / annuities**: usually protected, varies. +- **Trusts**: depends on trust type; generally outside the scope + of this TODO unless we get clean trust metadata. +- **Brokerage / taxable**: never shielded. Always counts as + exposed. +- **Cash in personal bank accounts**: never shielded. + +### Implementation options + +Two extremes: + +1. **Fully automatic** — infer shielding from `account_type` in + `accounts.srf` (e.g., `401k`, `roth_ira`, `traditional_ira`) + plus a static state-default table. Risk: state-by-state IRA + law is genuinely complex, and a wrong default could produce + a misleading number. The user would need to KNOW the table's + assumptions. +2. **Fully manual** — add a `shielded` boolean (or + `shielded_fraction`: 0.0–1.0) to each account in `accounts.srf`. + User decides per account. Most accurate, more upfront work. + +### Recommended hybrid + +- Add an `account_type` field if not already present (the + metadata for tax-treatment is likely already there for + contributions tracking). Map types to a default shielding: + - `401k`, `403b`, `457`, `pension` → `shielded = true` + - `roth_401k`, `roth_403b` → `shielded = true` + - `traditional_ira`, `roth_ira`, `sep_ira`, `simple_ira` → + `shielded = depends_on_state` (unknown without state info) + - `hsa` → `shielded = true` (with caveat) + - `brokerage`, `bank`, `joint`, `trust` → `shielded = false` +- Add an optional per-account `shielded` override in + `accounts.srf` (e.g. `shielded::true` / `shielded::false` / + `shielded::partial`) that wins over the default. This is the + escape hatch for "my state of residence treats IRAs as + shielded" or "this trust isn't protected." +- Add a `state` field at the user level (perhaps + `metadata.srf` or a config), with a built-in lookup table of + state IRA protection (`full`, `partial`, `none`) that + populates the default for IRA-type accounts when no override + is set. + +### Output + +In the analysis tab / command, add a section like: + +``` +Umbrella exposure + Total liquid value: $X,XXX,XXX + Shielded (401k/ERISA): $XXX,XXX + Shielded (IRA, NY default = partial): $XXX,XXX (per state default) + Shielded (override): $XXX,XXX + Net exposure: $X,XXX,XXX ← umbrella target +``` + +Show it both as an absolute dollar amount and as a percent of +liquid total. Include the assumption text (state, default for +that state) inline so the user knows what the number means and +when it might be wrong. + +### Tests + +- Default shielding for each `account_type`. +- Override wins over default. +- State table consistency (no missing states). +- "I don't know my state" path should refuse to guess for IRAs + and instead report the IRA-shielded portion as `unknown`, + forcing the user to choose between "treat as shielded" / + "treat as exposed" via override. +- Inherited IRAs flagged separately if we track that. + +### Open questions + +- Where should the state field live — `metadata.srf` (per + portfolio file) or a global user config (one user, one state)? +- Should we surface a "needed umbrella amount = net_exposure + + [home equity, vehicles]" figure, or strictly stay liquid? The + user asked about liquid assets specifically, so default to that + but leave a note. +- How to handle joint accounts where one spouse's lawsuit could + reach the other's share. State-specific (community property + vs separate property states). Probably out of scope for v1. + + The following items are acknowledged but not prioritized. Listed here so they don't get lost; pick up opportunistically. diff --git a/src/Config.zig b/src/Config.zig index 534f89c..0f580b9 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -566,7 +566,7 @@ test "globMatch: literal match" { test "globMatch: simple star" { try testing.expect(globMatch("portfolio*.srf", "portfolio.srf")); try testing.expect(globMatch("portfolio*.srf", "portfolio_main.srf")); - try testing.expect(globMatch("portfolio*.srf", "portfolio_mom.srf")); + try testing.expect(globMatch("portfolio*.srf", "portfolio_other.srf")); try testing.expect(!globMatch("portfolio*.srf", "watchlist.srf")); try testing.expect(!globMatch("portfolio*.srf", "portfolio.txt")); } diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 56c19d6..b0e9b02 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -420,18 +420,18 @@ fn mapToSortedBreakdown( test "parseAccountsFile" { const data = \\#!srfv1 - \\account::Emil Roth,tax_type::roth - \\account::Joint trust,tax_type::taxable - \\account::Fidelity Emil HSA,tax_type::hsa + \\account::Sample Roth,tax_type::roth + \\account::Sample Trust,tax_type::taxable + \\account::Sample HSA,tax_type::hsa ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 3), am.entries.len); - try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Emil Roth")); - try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Joint trust")); - try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Fidelity Emil HSA")); + try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Sample Roth")); + try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Sample Trust")); + try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Sample HSA")); try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent")); } @@ -444,16 +444,16 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti // findByInstitutionAccount can't silently drop the link. const data = \\#!srfv1 - \\account::Mom Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123 - \\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::716 + \\account::Sample Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123 + \\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::1234 ; const allocator = std.testing.allocator; var am = try parseAccountsFile(allocator, data); defer am.deinit(); try std.testing.expectEqual(@as(usize, 2), am.entries.len); - try std.testing.expectEqualStrings("Mom Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?); - try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "716").?); + try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?); + try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?); // Wrong institution / wrong number → null. try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null); try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null); diff --git a/src/analytics/timeline.zig b/src/analytics/timeline.zig index b3cfd19..e597aca 100644 --- a/src/analytics/timeline.zig +++ b/src/analytics/timeline.zig @@ -1449,7 +1449,7 @@ test "snapshotToPoint: missing totals default to zero" { test "snapshotToPoint: propagates accounts and tax_types" { const accts = [_]snapshot.AccountRow{ - .{ .kind = "account", .name = "Emil Roth", .value = 1000 }, + .{ .kind = "account", .name = "Sample Roth", .value = 1000 }, .{ .kind = "account", .name = "Kelly IRA", .value = 500 }, }; const taxes = [_]snapshot.TaxTypeRow{ @@ -1476,7 +1476,7 @@ test "snapshotToPoint: propagates accounts and tax_types" { defer p.deinit(testing.allocator); try testing.expectEqual(@as(usize, 2), p.accounts.len); - try testing.expectEqualStrings("Emil Roth", p.accounts[0].name); + try testing.expectEqualStrings("Sample Roth", p.accounts[0].name); try testing.expectEqual(@as(f64, 1000), p.accounts[0].value); try testing.expectEqual(@as(usize, 1), p.tax_types.len); diff --git a/src/brokerage/schwab.zig b/src/brokerage/schwab.zig index c7c279c..a7ae541 100644 --- a/src/brokerage/schwab.zig +++ b/src/brokerage/schwab.zig @@ -262,7 +262,7 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu var val_iter = std.mem.tokenizeAny(u8, lines[i + 1], &.{ ' ', '\t' }); while (val_iter.next()) |tok| { if (parseDollarAmount(tok)) |v| { - dollar_values.append(allocator, v) catch {}; + try dollar_values.append(allocator, v); } } if (dollar_values.items.len >= 2) { @@ -289,15 +289,15 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu // ── Tests ──────────────────────────────────────────────────── test "parseTitle" { - const t1 = parseTitle("\"Positions for account Joint trust ...716 as of 10:47 AM ET, 2026/04/10\""); + const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\""); try std.testing.expect(t1 != null); - try std.testing.expectEqualStrings("Joint trust", t1.?.name); - try std.testing.expectEqualStrings("716", t1.?.number); + try std.testing.expectEqualStrings("Sample Trust", t1.?.name); + try std.testing.expectEqualStrings("1234", t1.?.number); - const t2 = parseTitle("\"Positions for account Emil IRA ...118 as of 3:00 PM ET, 2026/04/10\""); + const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\""); try std.testing.expect(t2 != null); - try std.testing.expectEqualStrings("Emil IRA", t2.?.name); - try std.testing.expectEqualStrings("118", t2.?.number); + try std.testing.expectEqualStrings("Sample IRA", t2.?.name); + try std.testing.expectEqualStrings("5678", t2.?.number); try std.testing.expect(parseTitle("some random text") == null); } @@ -316,7 +316,7 @@ test "splitCsvLine" { test "parseCsv basic" { const csv = - "\"Positions for account Joint trust ...716 as of 10:47 AM ET, 2026/04/10\"\n" ++ + "\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++ "\n" ++ "\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++ "\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++ @@ -327,8 +327,8 @@ test "parseCsv basic" { const parsed = try parseCsv(allocator, csv); defer allocator.free(parsed.positions); - try std.testing.expectEqualStrings("Joint trust", parsed.account_name); - try std.testing.expectEqualStrings("716", parsed.account_number); + try std.testing.expectEqualStrings("Sample Trust", parsed.account_name); + try std.testing.expectEqualStrings("1234", parsed.account_number); try std.testing.expectEqual(@as(usize, 2), parsed.positions.len); @@ -348,11 +348,11 @@ test "parseCsv basic" { test "parseSummary basic" { const data = - \\Emil Roth - \\Account number ending in 901 ...901 + \\Sample Roth + \\Account number ending in 1234 ...1234 \\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47% \\Inherited IRA - \\Account number ending in 503 ...503 + \\Account number ending in 5678 ...5678 \\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73% ; const allocator = std.testing.allocator; @@ -361,13 +361,13 @@ test "parseSummary basic" { try std.testing.expectEqual(@as(usize, 2), accounts.len); - try std.testing.expectEqualStrings("Emil Roth", accounts[0].account_name); - try std.testing.expectEqualStrings("901", accounts[0].account_number); + try std.testing.expectEqualStrings("Sample Roth", accounts[0].account_name); + try std.testing.expectEqualStrings("1234", accounts[0].account_number); try std.testing.expectApproxEqAbs(@as(f64, 46.44), accounts[0].cash.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 227058.15), accounts[0].total_value.?, 0.01); try std.testing.expectEqualStrings("Inherited IRA", accounts[1].account_name); - try std.testing.expectEqualStrings("503", accounts[1].account_number); + try std.testing.expectEqualStrings("5678", accounts[1].account_number); try std.testing.expectApproxEqAbs(@as(f64, 2461.82), accounts[1].cash.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 167544.08), accounts[1].total_value.?, 0.01); } @@ -375,12 +375,12 @@ test "parseSummary basic" { test "parseSummary tolerates missing headers and extra blank lines" { const data = \\ - \\Joint trust - \\Account number ending in 716 ...716 + \\Sample Trust + \\Account number ending in 1234 ...1234 \\Type Brokerage $8,271.12 $849,087.12 +$20,488.80 +2.47% \\ \\Tax Loss - \\Account number ending in 311 ...311 + \\Account number ending in 5678 ...5678 \\$4,654.15 $488,481.18 +$1,686.91 +0.35% ; const allocator = std.testing.allocator; @@ -388,8 +388,8 @@ test "parseSummary tolerates missing headers and extra blank lines" { defer allocator.free(accounts); try std.testing.expectEqual(@as(usize, 2), accounts.len); - try std.testing.expectEqualStrings("Joint trust", accounts[0].account_name); - try std.testing.expectEqualStrings("716", accounts[0].account_number); + try std.testing.expectEqualStrings("Sample Trust", accounts[0].account_name); + try std.testing.expectEqualStrings("1234", accounts[0].account_number); // Second account has no "Type" prefix — parser still finds dollar amounts try std.testing.expectEqualStrings("Tax Loss", accounts[1].account_name); @@ -399,8 +399,8 @@ test "parseSummary tolerates missing headers and extra blank lines" { test "parseSummary skips summary footer" { const data = - \\Mom - \\Account number ending in 152 ...152 + \\Sample Account + \\Account number ending in 1234 ...1234 \\Type Brokerage $3,492.85 $161,676.14 +$749.40 +0.47% \\Investment Total \\$22,070.35 @@ -413,7 +413,7 @@ test "parseSummary skips summary footer" { defer allocator.free(accounts); try std.testing.expectEqual(@as(usize, 1), accounts.len); - try std.testing.expectEqualStrings("Mom", accounts[0].account_name); + try std.testing.expectEqualStrings("Sample Account", accounts[0].account_name); } test "parseSummary no accounts" { diff --git a/src/brokerage/wells_fargo.zig b/src/brokerage/wells_fargo.zig index a18efc8..626b2eb 100644 --- a/src/brokerage/wells_fargo.zig +++ b/src/brokerage/wells_fargo.zig @@ -63,6 +63,7 @@ //! fallback; the format itself doesn't tag cash distinctly. const std = @import("std"); +const builtin = @import("builtin"); const portfolio_mod = @import("../models/portfolio.zig"); const types = @import("types.zig"); const analysis = @import("../analytics/analysis.zig"); @@ -255,8 +256,8 @@ pub const Resolved = struct { /// path (without extension) and try to match it against the /// `account::` field of each WF entry, allowing for /// case-insensitive substring overlap on the trailing -/// `*NNNN` digits. Filename "Elizabeth_IRA_3522.txt" matches -/// "Elizabeth IRA *3522". +/// `*NNNN` digits. Filename "Sample_IRA_1234.txt" matches +/// "Sample IRA *1234". /// 3. **Single-WF-entry fallback.** If accounts.srf has exactly /// one `institution::wells_fargo` entry, use it. Helpful for /// users with one WF account; harmless when there are many @@ -286,14 +287,16 @@ pub fn resolveAccount( return resolutionFor(io, e); } } - var stderr_buf: [4096]u8 = undefined; - var sw = std.Io.File.stderr().writer(io, &stderr_buf); - try sw.interface.print( - "Error: --account '{s}' did not match any `institution::wells_fargo` entry in accounts.srf.\n", - .{name}, - ); - try printEntries(&sw.interface, account_map); - try sw.interface.flush(); + if (!builtin.is_test) { + var stderr_buf: [4096]u8 = undefined; + var sw = std.Io.File.stderr().writer(io, &stderr_buf); + try sw.interface.print( + "Error: --account '{s}' did not match any `institution::wells_fargo` entry in accounts.srf.\n", + .{name}, + ); + try printEntries(&sw.interface, account_map); + try sw.interface.flush(); + } return error.UnknownAccount; } @@ -336,24 +339,26 @@ pub fn resolveAccount( if (wf_count == 1) return resolutionFor(io, single.?); // Couldn't pick. Print enumerated guidance. - var stderr_buf: [4096]u8 = undefined; - var sw = std.Io.File.stderr().writer(io, &stderr_buf); - if (wf_count == 0) { - try sw.interface.print( - "Error: no `institution::wells_fargo` entries found in accounts.srf.\n" ++ - " Add one (e.g. `account::Elizabeth IRA *3522,tax_type::roth,institution::wells_fargo,account_number::3522`)\n" ++ - " and rerun the import.\n", - .{}, - ); - } else { - try sw.interface.print( - "Error: {d} Wells Fargo accounts in accounts.srf; cannot pick one automatically.\n" ++ - " Pass --account NAME to disambiguate. Candidates:\n", - .{wf_count}, - ); - try printEntries(&sw.interface, account_map); + if (!builtin.is_test) { + var stderr_buf: [4096]u8 = undefined; + var sw = std.Io.File.stderr().writer(io, &stderr_buf); + if (wf_count == 0) { + try sw.interface.print( + "Error: no `institution::wells_fargo` entries found in accounts.srf.\n" ++ + " Add one (e.g. `account::Sample IRA *1234,tax_type::roth,institution::wells_fargo,account_number::1234`)\n" ++ + " and rerun the import.\n", + .{}, + ); + } else { + try sw.interface.print( + "Error: {d} Wells Fargo accounts in accounts.srf; cannot pick one automatically.\n" ++ + " Pass --account NAME to disambiguate. Candidates:\n", + .{wf_count}, + ); + try printEntries(&sw.interface, account_map); + } + try sw.interface.flush(); } - try sw.interface.flush(); return error.AmbiguousWellsFargoAccount; } @@ -382,14 +387,16 @@ pub fn applyAccountToPositions( /// downstream `findByInstitutionAccount` lookup keys on it. fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved { const num = entry.account_number orelse { - var stderr_buf: [512]u8 = undefined; - var sw = std.Io.File.stderr().writer(io, &stderr_buf); - try sw.interface.print( - "Error: WF account '{s}' has no `account_number::` field in accounts.srf.\n" ++ - " Add one (the trailing digits after `*` work well, e.g. `account_number::3522`).\n", - .{entry.account}, - ); - try sw.interface.flush(); + if (!builtin.is_test) { + var stderr_buf: [512]u8 = undefined; + var sw = std.Io.File.stderr().writer(io, &stderr_buf); + try sw.interface.print( + "Error: WF account '{s}' has no `account_number::` field in accounts.srf.\n" ++ + " Add one (the trailing digits after `*` work well, e.g. `account_number::1234`).\n", + .{entry.account}, + ); + try sw.interface.flush(); + } return error.UnknownAccount; }; return .{ .account_number = num, .account_name = entry.account }; @@ -402,12 +409,12 @@ fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved { /// underscores and spaces treated as equivalent. /// /// Examples: -/// filenameMatchesAccount("Elizabeth_IRA_3522", "Elizabeth IRA *3522") → true -/// filenameMatchesAccount("eliz-ira-3522", "Elizabeth IRA *3522") → true (digits match) -/// filenameMatchesAccount("portfolio_mom", "Elizabeth IRA *3522") → false +/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234") → true +/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234") → true (digits match) +/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234") → false fn filenameMatchesAccount(filename: []const u8, account_name: []const u8) bool { // Extract the trailing digit run from the account name. - // "Elizabeth IRA *3522" → "3522". + // "Sample IRA *1234" → "1234". var digits_start: usize = account_name.len; while (digits_start > 0) { const c = account_name[digits_start - 1]; @@ -761,12 +768,12 @@ fn testAccountMap(allocator: std.mem.Allocator, entries: []const analysis.Accoun test "filenameMatchesAccount: trailing-digit anchor wins" { // Strongest signal — WF account suffixes are unique within // a household, so a digit-run match is unambiguous. - try testing.expect(filenameMatchesAccount("Elizabeth_IRA_3522", "Elizabeth IRA *3522")); - try testing.expect(filenameMatchesAccount("3522.txt", "Elizabeth IRA *3522")); - try testing.expect(filenameMatchesAccount("eliz-ira-3522", "Elizabeth IRA *3522")); + try testing.expect(filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234")); + try testing.expect(filenameMatchesAccount("1234.txt", "Sample IRA *1234")); + try testing.expect(filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234")); // Different digit suffix → no match. - try testing.expect(!filenameMatchesAccount("Elizabeth_IRA_7891", "Elizabeth IRA *3522")); - try testing.expect(!filenameMatchesAccount("portfolio_mom", "Elizabeth IRA *3522")); + try testing.expect(!filenameMatchesAccount("Sample_IRA_5678", "Sample IRA *1234")); + try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *1234")); } test "filenameMatchesAccount: alpha-only fallback when account has no digit suffix" { @@ -799,20 +806,20 @@ test "alphaRunsContained: every alphanumeric run from account appears in order" test "resolveAccount: explicit override matches a WF entry" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" }, - .{ .account = "Elizabeth Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" }, + .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, + .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" }, }); defer account_map.deinit(); - const r = try resolveAccount(testing.io, account_map, "anything.txt", "Elizabeth IRA *3522"); - try testing.expectEqualStrings("3522", r.account_number); - try testing.expectEqualStrings("Elizabeth IRA *3522", r.account_name); + const r = try resolveAccount(testing.io, account_map, "anything.txt", "Sample IRA *1234"); + try testing.expectEqualStrings("1234", r.account_number); + try testing.expectEqualStrings("Sample IRA *1234", r.account_name); } test "resolveAccount: explicit override that doesn't match → UnknownAccount" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" }, + .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, }); defer account_map.deinit(); @@ -822,33 +829,33 @@ test "resolveAccount: explicit override that doesn't match → UnknownAccount" { test "resolveAccount: filename inference picks the right entry from multiple WF accounts" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" }, - .{ .account = "Elizabeth Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" }, + .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, + .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" }, }); defer account_map.deinit(); - const r = try resolveAccount(testing.io, account_map, "/path/to/Elizabeth_IRA_3522.txt", null); - try testing.expectEqualStrings("3522", r.account_number); + const r = try resolveAccount(testing.io, account_map, "/path/to/Sample_IRA_1234.txt", null); + try testing.expectEqualStrings("1234", r.account_number); } test "resolveAccount: single-WF-entry fallback when filename has no signal" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" }, + .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, // Non-WF entry shouldn't interfere. - .{ .account = "Mom Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); const r = try resolveAccount(testing.io, account_map, "unrelated_filename.txt", null); - try testing.expectEqualStrings("3522", r.account_number); + try testing.expectEqualStrings("1234", r.account_number); } test "resolveAccount: ambiguous when 2+ WF entries and no signal" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" }, - .{ .account = "Elizabeth Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" }, + .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, + .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" }, }); defer account_map.deinit(); @@ -858,7 +865,7 @@ test "resolveAccount: ambiguous when 2+ WF entries and no signal" { test "resolveAccount: zero WF entries → AmbiguousWellsFargoAccount with helpful message" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -874,17 +881,17 @@ test "resolveAccount: WF entry without account_number → UnknownAccount" { // no useful hint about why. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Elizabeth IRA", .tax_type = .roth, .institution = "wells_fargo", .account_number = null }, + .{ .account = "Sample IRA", .tax_type = .roth, .institution = "wells_fargo", .account_number = null }, }); defer account_map.deinit(); - try testing.expectError(error.UnknownAccount, resolveAccount(testing.io, account_map, "Elizabeth_IRA.txt", null)); + try testing.expectError(error.UnknownAccount, resolveAccount(testing.io, account_map, "Sample_IRA.txt", null)); } test "applyAccountToPositions: patches every position's account fields" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" }, + .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, }); defer account_map.deinit(); @@ -893,9 +900,9 @@ test "applyAccountToPositions: patches every position's account fields" { .{ .account_number = "", .account_name = "", .symbol = "AAPL", .description = "", .quantity = 5, .current_value = 1000, .cost_basis = 750, .is_cash = false }, }; - try applyAccountToPositions(testing.io, account_map, "Elizabeth_IRA_3522.txt", null, &positions); - try testing.expectEqualStrings("3522", positions[0].account_number); - try testing.expectEqualStrings("Elizabeth IRA *3522", positions[0].account_name); - try testing.expectEqualStrings("3522", positions[1].account_number); - try testing.expectEqualStrings("Elizabeth IRA *3522", positions[1].account_name); + try applyAccountToPositions(testing.io, account_map, "Sample_IRA_1234.txt", null, &positions); + try testing.expectEqualStrings("1234", positions[0].account_number); + try testing.expectEqualStrings("Sample IRA *1234", positions[0].account_name); + try testing.expectEqualStrings("1234", positions[1].account_number); + try testing.expectEqualStrings("Sample IRA *1234", positions[1].account_name); } diff --git a/src/commands/audit.zig b/src/commands/audit.zig index b77c3ac..22cd027 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -2174,7 +2174,7 @@ test "option delta tracking in compareAccounts" { .open_date = Date.fromYmd(2025, 1, 1), .open_price = 6.68, .multiplier = 100, - .account = "Emil IRA", + .account = "Sample IRA", }, }; const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; @@ -2193,10 +2193,10 @@ test "option delta tracking in compareAccounts" { }, }; - // Account map: map schwab account 1234 -> portfolio "Emil IRA" + // Account map: map schwab account 1234 -> portfolio "Sample IRA" var entries = [_]analysis.AccountTaxEntry{ .{ - .account = "Emil IRA", + .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234", @@ -2246,7 +2246,7 @@ test "detectBrokerFileKind: fidelity csv with BOM" { } test "detectBrokerFileKind: schwab csv" { - const schwab_header = "\"Positions for account Roth IRA ...716 as of\""; + const schwab_header = "\"Positions for account Roth IRA ...1234 as of\""; try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?); } @@ -2411,7 +2411,7 @@ test "hasSchwabDiscrepancies" { const clean = [_]SchwabAccountComparison{.{ .account_name = "IRA", .schwab_name = "Roth IRA", - .account_number = "716", + .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, @@ -2425,7 +2425,7 @@ test "hasSchwabDiscrepancies" { const dirty = [_]SchwabAccountComparison{.{ .account_name = "IRA", .schwab_name = "Roth IRA", - .account_number = "716", + .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 200, .cash_delta = 100, @@ -2443,7 +2443,7 @@ test "detectBrokerFileKind: schwab csv with Positions header" { } test "detectBrokerFileKind: schwab summary with Roth IRA" { - const data = "Roth IRA ...716\nSome text\n$50,000.00\n"; + const data = "Roth IRA ...1234\nSome text\n$50,000.00\n"; try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(data).?); } @@ -2593,7 +2593,7 @@ test "printLargeLotWarning: cents are preserved in template" { var writer = std.Io.Writer.fixed(&buf); const lot: contributions.UnmatchedLargeLot = .{ - .account = "Joint trust", + .account = "Sample Trust", .symbol = "", .security_type = .cash, .value = 73_158.33, diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 9dfcdee..84d65f7 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -2885,7 +2885,7 @@ test "computeReport: matured CD with maturity_date <= today" { defer prices.deinit(); const before = [_]Lot{ - .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Emil IRA", .maturity_date = Date.fromYmd(2026, 4, 17) }, + .{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Sample IRA", .maturity_date = Date.fromYmd(2026, 4, 17) }, }; const after = [_]Lot{}; @@ -4539,7 +4539,7 @@ test "collectUnmatchedLargeLots: matched via transfer log is silent" { test "collectUnmatchedLargeLots: cash-destination matched is silent" { // Regression for the user-visible bug: a $73,158.33 cash lot on - // Joint trust funded by a transfer record dated 2026-05-20 was + // Sample Trust funded by a transfer record dated 2026-05-20 was // surfacing in audit's "Large new lots — confirm source" because // the cash matcher doesn't flip the original `new_cash` Change's // kind (it draws from `cash_attributed_by_account` instead). @@ -4553,12 +4553,12 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" { const before = [_]Lot{}; const after = [_]Lot{ - .{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint trust" }, + .{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 - \\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Fidelity Emil,to::Joint trust,dest_lot::cash + \\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Sample Source,to::Sample Trust,dest_lot::cash \\ ); @@ -4579,7 +4579,7 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" { }; try std.testing.expect(saw_new_cash); try std.testing.expect(saw_synthetic_transfer); - const attributed = report.cash_attributed_by_account.get("Joint trust") orelse 0; + const attributed = report.cash_attributed_by_account.get("Sample Trust") orelse 0; try std.testing.expectEqual(@as(f64, 73158.33), attributed); // The audit filter must subtract the attribution and stay quiet. @@ -4600,12 +4600,12 @@ test "collectUnmatchedLargeLots: cash-destination partial match surfaces residua const before = [_]Lot{}; const after = [_]Lot{ - .{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint trust" }, + .{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 - \\transfer::2026-05-20,type::cash,amount:num:30000,from::Fidelity Emil,to::Joint trust,dest_lot::cash + \\transfer::2026-05-20,type::cash,amount:num:30000,from::Sample Source,to::Sample Trust,dest_lot::cash \\ ); @@ -4629,12 +4629,12 @@ test "collectUnmatchedLargeLots: cash-destination partial below threshold is sil const before = [_]Lot{}; const after = [_]Lot{ - .{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint trust" }, + .{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" }, }; const tlog = try transaction_log.parseTransactionLogFile(allocator, \\#!srfv1 - \\transfer::2026-05-20,type::cash,amount:num:10000,from::Fidelity Emil,to::Joint trust,dest_lot::cash + \\transfer::2026-05-20,type::cash,amount:num:10000,from::Sample Source,to::Sample Trust,dest_lot::cash \\ ); diff --git a/src/commands/framework.zig b/src/commands/framework.zig index 55328f5..7a51a15 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -854,7 +854,7 @@ test "resolvePatterns: two patterns matching same dir union-merge" { defer tmp.cleanup(); try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_main.srf", .data = "x" }); - try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_mom.srf", .data = "x" }); + try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_other.srf", .data = "x" }); var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf); @@ -862,10 +862,10 @@ test "resolvePatterns: two patterns matching same dir union-merge" { const main_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_main.srf" }); defer testing.allocator.free(main_path); - const mom_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_mom.srf" }); - defer testing.allocator.free(mom_path); + const other_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_other.srf" }); + defer testing.allocator.free(other_path); - const patterns = [_][]const u8{ main_path, mom_path }; + const patterns = [_][]const u8{ main_path, other_path }; const config: zfin.Config = .{ .cache_dir = "/tmp" }; var result = try resolvePatterns(io, testing.allocator, config, &patterns); defer result.deinit(); diff --git a/src/commands/import.zig b/src/commands/import.zig index 717c231..36f5a52 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -88,10 +88,10 @@ //! ``` //! # 1. Download Fidelity positions CSV from the website. //! # 2. Run import; review the diff in git. -//! zfin -p portfolio_mom.srf import --fidelity ~/Downloads/positions.csv +//! zfin -p portfolio_other.srf import --fidelity ~/Downloads/positions.csv //! # 3. Inspect changes; commit when satisfied. -//! git diff portfolio_mom.srf -//! git add portfolio_mom.srf && git commit -m "Update mom's portfolio" +//! git diff portfolio_other.srf +//! git add portfolio_other.srf && git commit -m "Update managed portfolio" //! ``` //! //! ## Safety @@ -112,6 +112,7 @@ //! lots with broken account names. const std = @import("std"); +const builtin = @import("builtin"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); @@ -721,26 +722,31 @@ fn synthesizeLots( } } if (unmapped.items.len > 0) { - var stderr_buf: [4096]u8 = undefined; - var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); - try stderr_writer.interface.print( - "Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n", - .{ - unmapped.items.len, - if (unmapped.items.len == 1) "" else "s", - institution, - if (unmapped.items.len == 1) "is" else "are", - }, - ); - for (unmapped.items) |num| { - try stderr_writer.interface.print(" - {s}\n", .{num}); + // Skip the human-readable error message under tests to keep + // test output clean; the returned error is what the test + // assertions rely on. + if (!builtin.is_test) { + var stderr_buf: [4096]u8 = undefined; + var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); + try stderr_writer.interface.print( + "Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n", + .{ + unmapped.items.len, + if (unmapped.items.len == 1) "" else "s", + institution, + if (unmapped.items.len == 1) "is" else "are", + }, + ); + for (unmapped.items) |num| { + try stderr_writer.interface.print(" - {s}\n", .{num}); + } + try stderr_writer.interface.print( + "\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++ + "`account_number::` value, then rerun the import.\n", + .{institution}, + ); + try stderr_writer.interface.flush(); } - try stderr_writer.interface.print( - "\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++ - "`account_number::` value, then rerun the import.\n", - .{institution}, - ); - try stderr_writer.interface.flush(); return error.UnmappedAccount; } @@ -850,7 +856,7 @@ fn testAccountMap(allocator: std.mem.Allocator, entries: []const analysis.Accoun test "synthesizeLots: stock positions get open_price = cost_basis / quantity" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -875,7 +881,7 @@ test "synthesizeLots: stock positions get open_price = cost_basis / quantity" { try testing.expectEqualStrings("AAPL", lots[0].symbol); try testing.expectApproxEqAbs(@as(f64, 100), lots[0].shares, 0.01); try testing.expectApproxEqAbs(@as(f64, 120.0), lots[0].open_price, 0.01); // 12000 / 100 - try testing.expectEqualStrings("Mom Brokerage", lots[0].account.?); + try testing.expectEqualStrings("Sample Brokerage", lots[0].account.?); // No prior portfolio (`prior_lookup = null`) → new-lot path: // `open_date` is the sentinel and the note carries the // import date so the user can tell when it was first seen. @@ -889,14 +895,14 @@ test "synthesizeLots: stock positions get open_price = cost_basis / quantity" { test "synthesizeLots: missing cost_basis falls back to current_value" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "716" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" }, }); defer account_map.deinit(); const positions = [_]BrokeragePosition{ .{ - .account_number = "716", - .account_name = "Joint trust", + .account_number = "1234", + .account_name = "Sample Trust", .symbol = "MSFT", .description = "MICROSOFT", .quantity = 10, @@ -917,7 +923,7 @@ test "synthesizeLots: missing cost_basis falls back to current_value" { test "synthesizeLots: cash positions become security_type=cash with shares=value, price=1" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -955,7 +961,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m // prevent. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -973,7 +979,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m .shares = 1, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 95.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, .note = "imported fidelity 2024-01-15", }, @@ -1010,7 +1016,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m test "synthesizeLots: unmapped account_number fails with UnmappedAccount" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -1039,18 +1045,18 @@ test "synthesizeLots: unmapped account_number fails with UnmappedAccount" { } test "synthesizeLots: institution mismatch counts as unmapped" { - // Account number 716 is registered for schwab; an entry coming + // Account number 1234 is registered for schwab; an entry coming // in as fidelity must NOT match it. Catches the case where the // user has the same trailing digits at two brokerages. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "716" }, + .{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" }, }); defer account_map.deinit(); const positions = [_]BrokeragePosition{ .{ - .account_number = "716", + .account_number = "1234", .account_name = "Some Fidelity Account", .symbol = "AAPL", .description = "", @@ -1075,8 +1081,8 @@ test "synthesizeLots: institution mismatch counts as unmapped" { test "synthesizeLots: multi-account export fans out per-account" { const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "Z111" }, - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z222" }, + .{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "Z111" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z222" }, }); defer account_map.deinit(); @@ -1089,8 +1095,8 @@ test "synthesizeLots: multi-account export fans out per-account" { defer freeLots(allocator, lots); try testing.expectEqual(@as(usize, 2), lots.len); - try testing.expectEqualStrings("Mom Roth", lots[0].account.?); - try testing.expectEqualStrings("Mom Brokerage", lots[1].account.?); + try testing.expectEqualStrings("Sample Roth", lots[0].account.?); + try testing.expectEqualStrings("Sample Brokerage", lots[1].account.?); try testing.expectApproxEqAbs(@as(f64, 200.0), lots[0].open_price, 0.01); // 10000 / 50 try testing.expectApproxEqAbs(@as(f64, 180.0), lots[1].open_price, 0.01); // 18000 / 100 } @@ -1104,7 +1110,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op // come from the new export (positions might have grown). const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -1114,7 +1120,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op .shares = 100, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 90.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, .note = "imported fidelity 2024-06-01", }, @@ -1150,7 +1156,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n // note carries today's date. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -1162,7 +1168,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n .shares = 100, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 90.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, }, }; @@ -1198,7 +1204,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e // buy and the right basis for trailing-return math. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -1208,7 +1214,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e .shares = 50, .open_date = Date.fromYmd(2025, 8, 15), .open_price = 220.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, }, .{ @@ -1216,7 +1222,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e .shares = 50, .open_date = Date.fromYmd(2022, 3, 10), // earlier — should win .open_price = 150.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, }, .{ @@ -1224,7 +1230,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 180.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, }, }; @@ -1252,7 +1258,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" { // new lot. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -1264,7 +1270,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" { .open_price = 90.0, .close_date = Date.fromYmd(2025, 6, 1), .close_price = 200.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, }, }; @@ -1293,7 +1299,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot // limitation in the module doc-block. const allocator = testing.allocator; var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Mom Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, }); defer account_map.deinit(); @@ -1303,7 +1309,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 90.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, }, .{ @@ -1311,7 +1317,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .stock, }, }; @@ -1344,14 +1350,14 @@ test "PriorLotsLookup: cash lots are excluded from the lookup" { .shares = 1000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, - .account = "Mom Brokerage", + .account = "Sample Brokerage", .security_type = .cash, }, }; var prior = try PriorLotsLookup.init(allocator, &prior_lots); defer prior.deinit(); - try testing.expect((try prior.find("FZFXX", "Mom Brokerage")) == null); + try testing.expect((try prior.find("FZFXX", "Sample Brokerage")) == null); } test "parseArgs: --fidelity captures path" { diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 08280e5..d6345cc 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -1217,7 +1217,7 @@ test "renderSnapshot: lot rendering elides price/quote_date/stale when default" .kind = "lot", .symbol = "VTI", .lot_symbol = "VTI", - .account = "Emil Roth", + .account = "Sample Roth", .security_type = "Stock", .shares = 100, .open_price = 200.0, @@ -1233,7 +1233,7 @@ test "renderSnapshot: lot rendering elides price/quote_date/stale when default" .kind = "lot", .symbol = "Savings", .lot_symbol = "Savings", - .account = "Emil Roth", + .account = "Sample Roth", .security_type = "Cash", .shares = 50000, .open_price = 0, @@ -1283,7 +1283,7 @@ test "renderSnapshot: tax_type and account rows carry kind discriminator" { .{ .kind = "tax_type", .label = "Roth (Post-Tax)", .value = 3000 }, }; const accts = [_]AccountRow{ - .{ .kind = "account", .name = "Emil Roth", .value = 2500 }, + .{ .kind = "account", .name = "Sample Roth", .value = 2500 }, }; const snap: Snapshot = .{ .meta = .{ @@ -1303,7 +1303,7 @@ test "renderSnapshot: tax_type and account rows carry kind discriminator" { defer testing.allocator.free(rendered); try testing.expect(std.mem.indexOf(u8, rendered, "kind::tax_type,label::Taxable") != null); try testing.expect(std.mem.indexOf(u8, rendered, "kind::tax_type,label::Roth (Post-Tax)") != null); - try testing.expect(std.mem.indexOf(u8, rendered, "kind::account,name::Emil Roth") != null); + try testing.expect(std.mem.indexOf(u8, rendered, "kind::account,name::Sample Roth") != null); } test "renderSnapshot: front-matter emitted exactly once" { diff --git a/src/history.zig b/src/history.zig index 801313b..599a480 100644 --- a/src/history.zig +++ b/src/history.zig @@ -773,8 +773,8 @@ test "parseSnapshotBytes: with tax_type, account, and lot records" { \\kind::total,scope::net_worth,value:num:1500 \\kind::tax_type,label::Taxable,value:num:1000 \\kind::tax_type,label::Roth (Post-Tax),value:num:500 - \\kind::account,name::Emil Roth,value:num:800 - \\kind::lot,symbol::VTI,lot_symbol::VTI,account::Emil Roth,security_type::Stock,shares:num:10,open_price:num:200,cost_basis:num:2000,value:num:2500,price:num:250,quote_date::2026-04-17 + \\kind::account,name::Sample Roth,value:num:800 + \\kind::lot,symbol::VTI,lot_symbol::VTI,account::Sample Roth,security_type::Stock,shares:num:10,open_price:num:200,cost_basis:num:2000,value:num:2500,price:num:250,quote_date::2026-04-17 \\ ; var parsed = try parseLiteral(input); @@ -786,7 +786,7 @@ test "parseSnapshotBytes: with tax_type, account, and lot records" { try testing.expectEqualStrings("Roth (Post-Tax)", snap.tax_types[1].label); try testing.expectEqual(@as(usize, 1), snap.accounts.len); - try testing.expectEqualStrings("Emil Roth", snap.accounts[0].name); + try testing.expectEqualStrings("Sample Roth", snap.accounts[0].name); try testing.expectEqual(@as(f64, 800), snap.accounts[0].value); try testing.expectEqual(@as(usize, 1), snap.lots.len); diff --git a/src/main.zig b/src/main.zig index aafb6d9..966b9f1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -78,7 +78,7 @@ const usage_footer = \\ prevent shell expansion: \\ -p 'portfolio_*.srf' \\ Or repeat the flag for multiple files: - \\ -p portfolio.srf -p portfolio_mom.srf + \\ -p portfolio.srf -p portfolio_other.srf \\ metadata.srf and accounts.srf are loaded from \\ the same directory as the first resolved \\ portfolio file. diff --git a/src/models/transaction_log.zig b/src/models/transaction_log.zig index dff4c41..7bd2fa3 100644 --- a/src/models/transaction_log.zig +++ b/src/models/transaction_log.zig @@ -403,7 +403,7 @@ test "TransferRecord.eql: identical records" { .transfer = Date.fromYmd(2026, 5, 20), .type = .cash, .amount = 73158.0, - .from = "Fidelity Emil", + .from = "Sample Source", .to = "Sample Trust", .dest_lot = .cash, .note = null, @@ -412,7 +412,7 @@ test "TransferRecord.eql: identical records" { .transfer = Date.fromYmd(2026, 5, 20), .type = .cash, .amount = 73158.0, - .from = "Fidelity Emil", + .from = "Sample Source", .to = "Sample Trust", .dest_lot = .cash, .note = null,