tweak tests

This commit is contained in:
Emil Lerch 2026-05-23 11:25:39 -07:00
parent 4bb29f1432
commit 4a5a3612e3
12 changed files with 266 additions and 144 deletions

111
TODO.md
View file

@ -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 `flush` to see if it's a buffer-not-flushed issue, or by
serializing a known-good fixture in isolation. 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."
- **Sample 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.01.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.
- Sample 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 The following items are acknowledged but not prioritized. Listed here
so they don't get lost; pick up opportunistically. so they don't get lost; pick up opportunistically.

View file

@ -420,18 +420,18 @@ fn mapToSortedBreakdown(
test "parseAccountsFile" { test "parseAccountsFile" {
const data = const data =
\\#!srfv1 \\#!srfv1
\\account::Emil Roth,tax_type::roth \\account::Sample Roth,tax_type::roth
\\account::Joint Account,tax_type::taxable \\account::Sample Trust,tax_type::taxable
\\account::Fidelity Sample HSA,tax_type::hsa \\account::Sample HSA,tax_type::hsa
; ;
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data); var am = try parseAccountsFile(allocator, data);
defer am.deinit(); defer am.deinit();
try std.testing.expectEqual(@as(usize, 3), am.entries.len); 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("Roth (Post-Tax)", am.taxTypeFor("Sample Roth"));
try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Joint Account")); try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Sample Trust"));
try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Fidelity Sample HSA")); try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Sample HSA"));
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent")); try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent"));
} }
@ -444,7 +444,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti
// findByInstitutionAccount can't silently drop the link. // findByInstitutionAccount can't silently drop the link.
const data = const data =
\\#!srfv1 \\#!srfv1
\\account::Mom Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123 \\account::Sample Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123
\\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::1234 \\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::1234
; ;
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
@ -452,7 +452,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti
defer am.deinit(); defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len); 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("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?); try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?);
// Wrong institution / wrong number null. // Wrong institution / wrong number null.
try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null); try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);

View file

@ -1449,7 +1449,7 @@ test "snapshotToPoint: missing totals default to zero" {
test "snapshotToPoint: propagates accounts and tax_types" { test "snapshotToPoint: propagates accounts and tax_types" {
const accts = [_]snapshot.AccountRow{ const accts = [_]snapshot.AccountRow{
.{ .kind = "account", .name = "Emil Roth", .value = 1000 }, .{ .kind = "account", .name = "Sample Roth", .value = 1000 },
.{ .kind = "account", .name = "Riley IRA", .value = 500 }, .{ .kind = "account", .name = "Riley IRA", .value = 500 },
}; };
const taxes = [_]snapshot.TaxTypeRow{ const taxes = [_]snapshot.TaxTypeRow{
@ -1476,7 +1476,7 @@ test "snapshotToPoint: propagates accounts and tax_types" {
defer p.deinit(testing.allocator); defer p.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), p.accounts.len); 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(f64, 1000), p.accounts[0].value);
try testing.expectEqual(@as(usize, 1), p.tax_types.len); try testing.expectEqual(@as(usize, 1), p.tax_types.len);

View file

@ -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' }); var val_iter = std.mem.tokenizeAny(u8, lines[i + 1], &.{ ' ', '\t' });
while (val_iter.next()) |tok| { while (val_iter.next()) |tok| {
if (parseDollarAmount(tok)) |v| { if (parseDollarAmount(tok)) |v| {
dollar_values.append(allocator, v) catch {}; try dollar_values.append(allocator, v);
} }
} }
if (dollar_values.items.len >= 2) { if (dollar_values.items.len >= 2) {
@ -289,15 +289,15 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu
// Tests // Tests
test "parseTitle" { test "parseTitle" {
const t1 = parseTitle("\"Positions for account Joint Account ...1234 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.expect(t1 != null);
try std.testing.expectEqualStrings("Joint Account", t1.?.name); try std.testing.expectEqualStrings("Sample Trust", t1.?.name);
try std.testing.expectEqualStrings("1234", t1.?.number); try std.testing.expectEqualStrings("1234", t1.?.number);
const t2 = parseTitle("\"Positions for account Emil IRA ...7890 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.expect(t2 != null);
try std.testing.expectEqualStrings("Emil IRA", t2.?.name); try std.testing.expectEqualStrings("Sample IRA", t2.?.name);
try std.testing.expectEqualStrings("7890", t2.?.number); try std.testing.expectEqualStrings("5678", t2.?.number);
try std.testing.expect(parseTitle("some random text") == null); try std.testing.expect(parseTitle("some random text") == null);
} }
@ -316,7 +316,7 @@ test "splitCsvLine" {
test "parseCsv basic" { test "parseCsv basic" {
const csv = const csv =
"\"Positions for account Joint Account ...1234 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" ++ "\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" ++ "\"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" ++ "\"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,7 +327,7 @@ test "parseCsv basic" {
const parsed = try parseCsv(allocator, csv); const parsed = try parseCsv(allocator, csv);
defer allocator.free(parsed.positions); defer allocator.free(parsed.positions);
try std.testing.expectEqualStrings("Joint Account", parsed.account_name); try std.testing.expectEqualStrings("Sample Trust", parsed.account_name);
try std.testing.expectEqualStrings("1234", parsed.account_number); try std.testing.expectEqualStrings("1234", parsed.account_number);
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len); try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
@ -348,11 +348,11 @@ test "parseCsv basic" {
test "parseSummary basic" { test "parseSummary basic" {
const data = const data =
\\Emil Roth \\Sample Roth
\\Account number ending in 5678 ...5678 \\Account number ending in 1234 ...1234
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47% \\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
\\Sample Inherited IRA \\Sample Inherited IRA
\\Account number ending in 9012 ...9012 \\Account number ending in 5678 ...5678
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73% \\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
; ;
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
@ -361,13 +361,13 @@ test "parseSummary basic" {
try std.testing.expectEqual(@as(usize, 2), accounts.len); try std.testing.expectEqual(@as(usize, 2), accounts.len);
try std.testing.expectEqualStrings("Emil Roth", accounts[0].account_name); try std.testing.expectEqualStrings("Sample Roth", accounts[0].account_name);
try std.testing.expectEqualStrings("5678", accounts[0].account_number); 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, 46.44), accounts[0].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 227058.15), accounts[0].total_value.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 227058.15), accounts[0].total_value.?, 0.01);
try std.testing.expectEqualStrings("Sample Inherited IRA", accounts[1].account_name); try std.testing.expectEqualStrings("Sample Inherited IRA", accounts[1].account_name);
try std.testing.expectEqualStrings("9012", 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, 2461.82), accounts[1].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 167544.08), accounts[1].total_value.?, 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" { test "parseSummary tolerates missing headers and extra blank lines" {
const data = const data =
\\ \\
\\Joint Account \\Sample Trust
\\Account number ending in 1234 ...1234 \\Account number ending in 1234 ...1234
\\Type Brokerage $8,271.12 $849,087.12 +$20,488.80 +2.47% \\Type Brokerage $8,271.12 $849,087.12 +$20,488.80 +2.47%
\\ \\
\\Tax Loss \\Tax Loss
\\Account number ending in 2345 ...2345 \\Account number ending in 5678 ...5678
\\$4,654.15 $488,481.18 +$1,686.91 +0.35% \\$4,654.15 $488,481.18 +$1,686.91 +0.35%
; ;
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
@ -388,7 +388,7 @@ test "parseSummary tolerates missing headers and extra blank lines" {
defer allocator.free(accounts); defer allocator.free(accounts);
try std.testing.expectEqual(@as(usize, 2), accounts.len); try std.testing.expectEqual(@as(usize, 2), accounts.len);
try std.testing.expectEqualStrings("Joint Account", accounts[0].account_name); try std.testing.expectEqualStrings("Sample Trust", accounts[0].account_name);
try std.testing.expectEqualStrings("1234", accounts[0].account_number); try std.testing.expectEqualStrings("1234", accounts[0].account_number);
// Second account has no "Type" prefix parser still finds dollar amounts // Second account has no "Type" prefix parser still finds dollar amounts
@ -399,8 +399,8 @@ test "parseSummary tolerates missing headers and extra blank lines" {
test "parseSummary skips summary footer" { test "parseSummary skips summary footer" {
const data = const data =
\\Mom \\Sample Account
\\Account number ending in 152 ...152 \\Account number ending in 1234 ...1234
\\Type Brokerage $3,492.85 $161,676.14 +$749.40 +0.47% \\Type Brokerage $3,492.85 $161,676.14 +$749.40 +0.47%
\\Investment Total \\Investment Total
\\$22,070.35 \\$22,070.35
@ -413,7 +413,7 @@ test "parseSummary skips summary footer" {
defer allocator.free(accounts); defer allocator.free(accounts);
try std.testing.expectEqual(@as(usize, 1), accounts.len); 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" { test "parseSummary no accounts" {

View file

@ -63,6 +63,7 @@
//! fallback; the format itself doesn't tag cash distinctly. //! fallback; the format itself doesn't tag cash distinctly.
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const portfolio_mod = @import("../models/portfolio.zig"); const portfolio_mod = @import("../models/portfolio.zig");
const types = @import("types.zig"); const types = @import("types.zig");
const analysis = @import("../analytics/analysis.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 /// path (without extension) and try to match it against the
/// `account::` field of each WF entry, allowing for /// `account::` field of each WF entry, allowing for
/// case-insensitive substring overlap on the trailing /// case-insensitive substring overlap on the trailing
/// `*NNNN` digits. Filename "Sample_IRA_3522.txt" matches /// `*NNNN` digits. Filename "Sample_IRA_1234.txt" matches
/// "Sample IRA *4567". /// "Sample IRA *1234".
/// 3. **Single-WF-entry fallback.** If accounts.srf has exactly /// 3. **Single-WF-entry fallback.** If accounts.srf has exactly
/// one `institution::wells_fargo` entry, use it. Helpful for /// one `institution::wells_fargo` entry, use it. Helpful for
/// users with one WF account; harmless when there are many /// users with one WF account; harmless when there are many
@ -286,14 +287,16 @@ pub fn resolveAccount(
return resolutionFor(io, e); return resolutionFor(io, e);
} }
} }
var stderr_buf: [4096]u8 = undefined; if (!builtin.is_test) {
var sw = std.Io.File.stderr().writer(io, &stderr_buf); var stderr_buf: [4096]u8 = undefined;
try sw.interface.print( var sw = std.Io.File.stderr().writer(io, &stderr_buf);
"Error: --account '{s}' did not match any `institution::wells_fargo` entry in accounts.srf.\n", try sw.interface.print(
.{name}, "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(); try printEntries(&sw.interface, account_map);
try sw.interface.flush();
}
return error.UnknownAccount; return error.UnknownAccount;
} }
@ -336,24 +339,26 @@ pub fn resolveAccount(
if (wf_count == 1) return resolutionFor(io, single.?); if (wf_count == 1) return resolutionFor(io, single.?);
// Couldn't pick. Print enumerated guidance. // Couldn't pick. Print enumerated guidance.
var stderr_buf: [4096]u8 = undefined; if (!builtin.is_test) {
var sw = std.Io.File.stderr().writer(io, &stderr_buf); var stderr_buf: [4096]u8 = undefined;
if (wf_count == 0) { var sw = std.Io.File.stderr().writer(io, &stderr_buf);
try sw.interface.print( if (wf_count == 0) {
"Error: no `institution::wells_fargo` entries found in accounts.srf.\n" ++ try sw.interface.print(
" Add one (e.g. `account::Sample IRA *4567,tax_type::roth,institution::wells_fargo,account_number::4567`)\n" ++ "Error: no `institution::wells_fargo` entries found in accounts.srf.\n" ++
" and rerun the import.\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( } else {
"Error: {d} Wells Fargo accounts in accounts.srf; cannot pick one automatically.\n" ++ try sw.interface.print(
" Pass --account NAME to disambiguate. Candidates:\n", "Error: {d} Wells Fargo accounts in accounts.srf; cannot pick one automatically.\n" ++
.{wf_count}, " Pass --account NAME to disambiguate. Candidates:\n",
); .{wf_count},
try printEntries(&sw.interface, account_map); );
try printEntries(&sw.interface, account_map);
}
try sw.interface.flush();
} }
try sw.interface.flush();
return error.AmbiguousWellsFargoAccount; return error.AmbiguousWellsFargoAccount;
} }
@ -382,14 +387,16 @@ pub fn applyAccountToPositions(
/// downstream `findByInstitutionAccount` lookup keys on it. /// downstream `findByInstitutionAccount` lookup keys on it.
fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved { fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved {
const num = entry.account_number orelse { const num = entry.account_number orelse {
var stderr_buf: [512]u8 = undefined; if (!builtin.is_test) {
var sw = std.Io.File.stderr().writer(io, &stderr_buf); var stderr_buf: [512]u8 = undefined;
try sw.interface.print( var sw = std.Io.File.stderr().writer(io, &stderr_buf);
"Error: WF account '{s}' has no `account_number::` field in accounts.srf.\n" ++ try sw.interface.print(
" Add one (the trailing digits after `*` work well, e.g. `account_number::4567`).\n", "Error: WF account '{s}' has no `account_number::` field in accounts.srf.\n" ++
.{entry.account}, " Add one (the trailing digits after `*` work well, e.g. `account_number::1234`).\n",
); .{entry.account},
try sw.interface.flush(); );
try sw.interface.flush();
}
return error.UnknownAccount; return error.UnknownAccount;
}; };
return .{ .account_number = num, .account_name = entry.account }; 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. /// underscores and spaces treated as equivalent.
/// ///
/// Examples: /// Examples:
/// filenameMatchesAccount("Sample_IRA_3522", "Sample IRA *4567") true /// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234") true
/// filenameMatchesAccount("eliz-ira-3522", "Sample IRA *4567") true (digits match) /// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234") true (digits match)
/// filenameMatchesAccount("portfolio_other", "Sample IRA *4567") false /// filenameMatchesAccount("portfolio_other", "Sample IRA *1234") false
fn filenameMatchesAccount(filename: []const u8, account_name: []const u8) bool { fn filenameMatchesAccount(filename: []const u8, account_name: []const u8) bool {
// Extract the trailing digit run from the account name. // Extract the trailing digit run from the account name.
// "Sample IRA *4567" "4567". // "Sample IRA *1234" "1234".
var digits_start: usize = account_name.len; var digits_start: usize = account_name.len;
while (digits_start > 0) { while (digits_start > 0) {
const c = account_name[digits_start - 1]; 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" { test "filenameMatchesAccount: trailing-digit anchor wins" {
// Strongest signal WF account suffixes are unique within // Strongest signal WF account suffixes are unique within
// a household, so a digit-run match is unambiguous. // a household, so a digit-run match is unambiguous.
try testing.expect(filenameMatchesAccount("Sample_IRA_3522", "Sample IRA *4567")); try testing.expect(filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234"));
try testing.expect(filenameMatchesAccount("3522.txt", "Sample IRA *4567")); try testing.expect(filenameMatchesAccount("1234.txt", "Sample IRA *1234"));
try testing.expect(filenameMatchesAccount("eliz-ira-3522", "Sample IRA *4567")); try testing.expect(filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234"));
// Different digit suffix no match. // Different digit suffix no match.
try testing.expect(!filenameMatchesAccount("Sample_IRA_7891", "Sample IRA *4567")); try testing.expect(!filenameMatchesAccount("Sample_IRA_5678", "Sample IRA *1234"));
try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *4567")); try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *1234"));
} }
test "filenameMatchesAccount: alpha-only fallback when account has no digit suffix" { 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" { test "resolveAccount: explicit override matches a WF entry" {
const allocator = testing.allocator; const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{ var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" }, .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
.{ .account = "Sample Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" }, .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" },
}); });
defer account_map.deinit(); defer account_map.deinit();
const r = try resolveAccount(testing.io, account_map, "anything.txt", "Sample IRA *4567"); const r = try resolveAccount(testing.io, account_map, "anything.txt", "Sample IRA *1234");
try testing.expectEqualStrings("4567", r.account_number); try testing.expectEqualStrings("1234", r.account_number);
try testing.expectEqualStrings("Sample IRA *4567", r.account_name); try testing.expectEqualStrings("Sample IRA *1234", r.account_name);
} }
test "resolveAccount: explicit override that doesn't match → UnknownAccount" { test "resolveAccount: explicit override that doesn't match → UnknownAccount" {
const allocator = testing.allocator; const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{ var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" }, .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
}); });
defer account_map.deinit(); 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" { test "resolveAccount: filename inference picks the right entry from multiple WF accounts" {
const allocator = testing.allocator; const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{ var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" }, .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
.{ .account = "Sample Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" }, .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" },
}); });
defer account_map.deinit(); defer account_map.deinit();
const r = try resolveAccount(testing.io, account_map, "/path/to/Sample_IRA_3522.txt", null); const r = try resolveAccount(testing.io, account_map, "/path/to/Sample_IRA_1234.txt", null);
try testing.expectEqualStrings("4567", r.account_number); try testing.expectEqualStrings("1234", r.account_number);
} }
test "resolveAccount: single-WF-entry fallback when filename has no signal" { test "resolveAccount: single-WF-entry fallback when filename has no signal" {
const allocator = testing.allocator; const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{ var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" }, .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
// Non-WF entry shouldn't interfere. // 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(); defer account_map.deinit();
const r = try resolveAccount(testing.io, account_map, "unrelated_filename.txt", null); const r = try resolveAccount(testing.io, account_map, "unrelated_filename.txt", null);
try testing.expectEqualStrings("4567", r.account_number); try testing.expectEqualStrings("1234", r.account_number);
} }
test "resolveAccount: ambiguous when 2+ WF entries and no signal" { test "resolveAccount: ambiguous when 2+ WF entries and no signal" {
const allocator = testing.allocator; const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{ var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" }, .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
.{ .account = "Sample Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" }, .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" },
}); });
defer account_map.deinit(); 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" { test "resolveAccount: zero WF entries → AmbiguousWellsFargoAccount with helpful message" {
const allocator = testing.allocator; const allocator = testing.allocator;
var account_map = try testAccountMap(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(); defer account_map.deinit();
@ -884,7 +891,7 @@ test "resolveAccount: WF entry without account_number → UnknownAccount" {
test "applyAccountToPositions: patches every position's account fields" { test "applyAccountToPositions: patches every position's account fields" {
const allocator = testing.allocator; const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{ var account_map = try testAccountMap(allocator, &.{
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" }, .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
}); });
defer account_map.deinit(); 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 }, .{ .account_number = "", .account_name = "", .symbol = "AAPL", .description = "", .quantity = 5, .current_value = 1000, .cost_basis = 750, .is_cash = false },
}; };
try applyAccountToPositions(testing.io, account_map, "Sample_IRA_3522.txt", null, &positions); try applyAccountToPositions(testing.io, account_map, "Sample_IRA_1234.txt", null, &positions);
try testing.expectEqualStrings("4567", positions[0].account_number); try testing.expectEqualStrings("1234", positions[0].account_number);
try testing.expectEqualStrings("Sample IRA *4567", positions[0].account_name); try testing.expectEqualStrings("Sample IRA *1234", positions[0].account_name);
try testing.expectEqualStrings("4567", positions[1].account_number); try testing.expectEqualStrings("1234", positions[1].account_number);
try testing.expectEqualStrings("Sample IRA *4567", positions[1].account_name); try testing.expectEqualStrings("Sample IRA *1234", positions[1].account_name);
} }

View file

@ -2174,7 +2174,7 @@ test "option delta tracking in compareAccounts" {
.open_date = Date.fromYmd(2025, 1, 1), .open_date = Date.fromYmd(2025, 1, 1),
.open_price = 6.68, .open_price = 6.68,
.multiplier = 100, .multiplier = 100,
.account = "Emil IRA", .account = "Sample IRA",
}, },
}; };
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; 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{ var entries = [_]analysis.AccountTaxEntry{
.{ .{
.account = "Emil IRA", .account = "Sample IRA",
.tax_type = .roth, .tax_type = .roth,
.institution = "schwab", .institution = "schwab",
.account_number = "1234", .account_number = "1234",
@ -2593,7 +2593,7 @@ test "printLargeLotWarning: cents are preserved in template" {
var writer = std.Io.Writer.fixed(&buf); var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{ const lot: contributions.UnmatchedLargeLot = .{
.account = "Joint Account", .account = "Sample Trust",
.symbol = "", .symbol = "",
.security_type = .cash, .security_type = .cash,
.value = 73_158.33, .value = 73_158.33,

View file

@ -2885,7 +2885,7 @@ test "computeReport: matured CD with maturity_date <= today" {
defer prices.deinit(); defer prices.deinit();
const before = [_]Lot{ 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{}; const after = [_]Lot{};
@ -4539,7 +4539,7 @@ test "collectUnmatchedLargeLots: matched via transfer log is silent" {
test "collectUnmatchedLargeLots: cash-destination matched is silent" { test "collectUnmatchedLargeLots: cash-destination matched is silent" {
// Regression for the user-visible bug: a $73,158.33 cash lot on // Regression for the user-visible bug: a $73,158.33 cash lot on
// Joint Account 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 // surfacing in audit's "Large new lots — confirm source" because
// the cash matcher doesn't flip the original `new_cash` Change's // the cash matcher doesn't flip the original `new_cash` Change's
// kind (it draws from `cash_attributed_by_account` instead). // kind (it draws from `cash_attributed_by_account` instead).
@ -4553,12 +4553,12 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" {
const before = [_]Lot{}; const before = [_]Lot{};
const after = [_]Lot{ const after = [_]Lot{
.{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint Account" }, .{ .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, const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1 \\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Fidelity Sample,to::Joint Account,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_new_cash);
try std.testing.expect(saw_synthetic_transfer); try std.testing.expect(saw_synthetic_transfer);
const attributed = report.cash_attributed_by_account.get("Joint Account") orelse 0; const attributed = report.cash_attributed_by_account.get("Sample Trust") orelse 0;
try std.testing.expectEqual(@as(f64, 73158.33), attributed); try std.testing.expectEqual(@as(f64, 73158.33), attributed);
// The audit filter must subtract the attribution and stay quiet. // 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 before = [_]Lot{};
const after = [_]Lot{ const after = [_]Lot{
.{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint Account" }, .{ .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, const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1 \\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:30000,from::Fidelity Sample,to::Joint Account,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 before = [_]Lot{};
const after = [_]Lot{ const after = [_]Lot{
.{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint Account" }, .{ .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, const tlog = try transaction_log.parseTransactionLogFile(allocator,
\\#!srfv1 \\#!srfv1
\\transfer::2026-05-20,type::cash,amount:num:10000,from::Fidelity Sample,to::Joint Account,dest_lot::cash \\transfer::2026-05-20,type::cash,amount:num:10000,from::Sample Source,to::Sample Trust,dest_lot::cash
\\ \\
); );

View file

@ -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" }); const main_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_main.srf" });
defer testing.allocator.free(main_path); defer testing.allocator.free(main_path);
const mom_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_other.srf" }); const other_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_other.srf" });
defer testing.allocator.free(mom_path); 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" }; const config: zfin.Config = .{ .cache_dir = "/tmp" };
var result = try resolvePatterns(io, testing.allocator, config, &patterns); var result = try resolvePatterns(io, testing.allocator, config, &patterns);
defer result.deinit(); defer result.deinit();

View file

@ -91,7 +91,7 @@
//! zfin -p portfolio_other.srf import --fidelity ~/Downloads/positions.csv //! zfin -p portfolio_other.srf import --fidelity ~/Downloads/positions.csv
//! # 3. Inspect changes; commit when satisfied. //! # 3. Inspect changes; commit when satisfied.
//! git diff portfolio_other.srf //! git diff portfolio_other.srf
//! git add portfolio_other.srf && git commit -m "Update mom's portfolio" //! git add portfolio_other.srf && git commit -m "Update managed portfolio"
//! ``` //! ```
//! //!
//! ## Safety //! ## Safety
@ -112,6 +112,7 @@
//! lots with broken account names. //! lots with broken account names.
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
@ -721,26 +722,31 @@ fn synthesizeLots(
} }
} }
if (unmapped.items.len > 0) { if (unmapped.items.len > 0) {
var stderr_buf: [4096]u8 = undefined; // Skip the human-readable error message under tests to keep
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); // test output clean; the returned error is what the test
try stderr_writer.interface.print( // assertions rely on.
"Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n", if (!builtin.is_test) {
.{ var stderr_buf: [4096]u8 = undefined;
unmapped.items.len, var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
if (unmapped.items.len == 1) "" else "s", try stderr_writer.interface.print(
institution, "Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n",
if (unmapped.items.len == 1) "is" else "are", .{
}, unmapped.items.len,
); if (unmapped.items.len == 1) "" else "s",
for (unmapped.items) |num| { institution,
try stderr_writer.interface.print(" - {s}\n", .{num}); 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; return error.UnmappedAccount;
} }
@ -896,7 +902,7 @@ test "synthesizeLots: missing cost_basis falls back to current_value" {
const positions = [_]BrokeragePosition{ const positions = [_]BrokeragePosition{
.{ .{
.account_number = "1234", .account_number = "1234",
.account_name = "Joint Account", .account_name = "Sample Trust",
.symbol = "MSFT", .symbol = "MSFT",
.description = "MICROSOFT", .description = "MICROSOFT",
.quantity = 10, .quantity = 10,
@ -1039,7 +1045,7 @@ test "synthesizeLots: unmapped account_number fails with UnmappedAccount" {
} }
test "synthesizeLots: institution mismatch counts as unmapped" { 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 // in as fidelity must NOT match it. Catches the case where the
// user has the same trailing digits at two brokerages. // user has the same trailing digits at two brokerages.
const allocator = testing.allocator; const allocator = testing.allocator;

View file

@ -1217,7 +1217,7 @@ test "renderSnapshot: lot rendering elides price/quote_date/stale when default"
.kind = "lot", .kind = "lot",
.symbol = "VTI", .symbol = "VTI",
.lot_symbol = "VTI", .lot_symbol = "VTI",
.account = "Emil Roth", .account = "Sample Roth",
.security_type = "Stock", .security_type = "Stock",
.shares = 100, .shares = 100,
.open_price = 200.0, .open_price = 200.0,
@ -1233,7 +1233,7 @@ test "renderSnapshot: lot rendering elides price/quote_date/stale when default"
.kind = "lot", .kind = "lot",
.symbol = "Savings", .symbol = "Savings",
.lot_symbol = "Savings", .lot_symbol = "Savings",
.account = "Emil Roth", .account = "Sample Roth",
.security_type = "Cash", .security_type = "Cash",
.shares = 50000, .shares = 50000,
.open_price = 0, .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 }, .{ .kind = "tax_type", .label = "Roth (Post-Tax)", .value = 3000 },
}; };
const accts = [_]AccountRow{ const accts = [_]AccountRow{
.{ .kind = "account", .name = "Emil Roth", .value = 2500 }, .{ .kind = "account", .name = "Sample Roth", .value = 2500 },
}; };
const snap: Snapshot = .{ const snap: Snapshot = .{
.meta = .{ .meta = .{
@ -1303,7 +1303,7 @@ test "renderSnapshot: tax_type and account rows carry kind discriminator" {
defer testing.allocator.free(rendered); 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::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::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" { test "renderSnapshot: front-matter emitted exactly once" {

View file

@ -773,8 +773,8 @@ test "parseSnapshotBytes: with tax_type, account, and lot records" {
\\kind::total,scope::net_worth,value:num:1500 \\kind::total,scope::net_worth,value:num:1500
\\kind::tax_type,label::Taxable,value:num:1000 \\kind::tax_type,label::Taxable,value:num:1000
\\kind::tax_type,label::Roth (Post-Tax),value:num:500 \\kind::tax_type,label::Roth (Post-Tax),value:num:500
\\kind::account,name::Emil Roth,value:num:800 \\kind::account,name::Sample 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::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); 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.expectEqualStrings("Roth (Post-Tax)", snap.tax_types[1].label);
try testing.expectEqual(@as(usize, 1), snap.accounts.len); 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(f64, 800), snap.accounts[0].value);
try testing.expectEqual(@as(usize, 1), snap.lots.len); try testing.expectEqual(@as(usize, 1), snap.lots.len);

View file

@ -403,7 +403,7 @@ test "TransferRecord.eql: identical records" {
.transfer = Date.fromYmd(2026, 5, 20), .transfer = Date.fromYmd(2026, 5, 20),
.type = .cash, .type = .cash,
.amount = 73158.0, .amount = 73158.0,
.from = "Fidelity Sample", .from = "Sample Source",
.to = "Sample Trust", .to = "Sample Trust",
.dest_lot = .cash, .dest_lot = .cash,
.note = null, .note = null,
@ -412,7 +412,7 @@ test "TransferRecord.eql: identical records" {
.transfer = Date.fromYmd(2026, 5, 20), .transfer = Date.fromYmd(2026, 5, 20),
.type = .cash, .type = .cash,
.amount = 73158.0, .amount = 73158.0,
.from = "Fidelity Sample", .from = "Sample Source",
.to = "Sample Trust", .to = "Sample Trust",
.dest_lot = .cash, .dest_lot = .cash,
.note = null, .note = null,