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
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
so they don't get lost; pick up opportunistically.

View file

@ -420,18 +420,18 @@ fn mapToSortedBreakdown(
test "parseAccountsFile" {
const data =
\\#!srfv1
\\account::Emil Roth,tax_type::roth
\\account::Joint Account,tax_type::taxable
\\account::Fidelity Sample 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 Account"));
try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Fidelity Sample 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,7 +444,7 @@ 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::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;
@ -452,7 +452,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti
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("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);

View file

@ -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 = "Riley 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);

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' });
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 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.expectEqualStrings("Joint Account", t1.?.name);
try std.testing.expectEqualStrings("Sample Trust", t1.?.name);
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.expectEqualStrings("Emil IRA", t2.?.name);
try std.testing.expectEqualStrings("7890", 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 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" ++
"\"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,7 +327,7 @@ test "parseCsv basic" {
const parsed = try parseCsv(allocator, csv);
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.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 5678 ...5678
\\Sample Roth
\\Account number ending in 1234 ...1234
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
\\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%
;
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("5678", 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("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, 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 Account
\\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 2345 ...2345
\\Account number ending in 5678 ...5678
\\$4,654.15 $488,481.18 +$1,686.91 +0.35%
;
const allocator = std.testing.allocator;
@ -388,7 +388,7 @@ 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 Account", accounts[0].account_name);
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
@ -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" {

View file

@ -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 "Sample_IRA_3522.txt" matches
/// "Sample IRA *4567".
/// `*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::Sample IRA *4567,tax_type::roth,institution::wells_fargo,account_number::4567`)\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::4567`).\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("Sample_IRA_3522", "Sample IRA *4567") true
/// filenameMatchesAccount("eliz-ira-3522", "Sample IRA *4567") true (digits match)
/// filenameMatchesAccount("portfolio_other", "Sample IRA *4567") 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.
// "Sample IRA *4567" "4567".
// "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("Sample_IRA_3522", "Sample IRA *4567"));
try testing.expect(filenameMatchesAccount("3522.txt", "Sample IRA *4567"));
try testing.expect(filenameMatchesAccount("eliz-ira-3522", "Sample IRA *4567"));
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("Sample_IRA_7891", "Sample IRA *4567"));
try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *4567"));
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 = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
.{ .account = "Sample 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", "Sample IRA *4567");
try testing.expectEqualStrings("4567", r.account_number);
try testing.expectEqualStrings("Sample IRA *4567", 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 = "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();
@ -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 = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
.{ .account = "Sample 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/Sample_IRA_3522.txt", null);
try testing.expectEqualStrings("4567", 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 = "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.
.{ .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("4567", 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 = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
.{ .account = "Sample 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();
@ -884,7 +891,7 @@ test "resolveAccount: WF entry without account_number → UnknownAccount" {
test "applyAccountToPositions: patches every position's account fields" {
const allocator = testing.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();
@ -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, "Sample_IRA_3522.txt", null, &positions);
try testing.expectEqualStrings("4567", positions[0].account_number);
try testing.expectEqualStrings("Sample IRA *4567", positions[0].account_name);
try testing.expectEqualStrings("4567", positions[1].account_number);
try testing.expectEqualStrings("Sample IRA *4567", 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);
}

View file

@ -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",
@ -2593,7 +2593,7 @@ test "printLargeLotWarning: cents are preserved in template" {
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Joint Account",
.account = "Sample Trust",
.symbol = "",
.security_type = .cash,
.value = 73_158.33,

View file

@ -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 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
// 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 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,
\\#!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_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);
// 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 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,
\\#!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 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,
\\#!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" });
defer testing.allocator.free(main_path);
const mom_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_other.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();

View file

@ -91,7 +91,7 @@
//! zfin -p portfolio_other.srf import --fidelity ~/Downloads/positions.csv
//! # 3. Inspect changes; commit when satisfied.
//! 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
@ -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;
}
@ -896,7 +902,7 @@ test "synthesizeLots: missing cost_basis falls back to current_value" {
const positions = [_]BrokeragePosition{
.{
.account_number = "1234",
.account_name = "Joint Account",
.account_name = "Sample Trust",
.symbol = "MSFT",
.description = "MICROSOFT",
.quantity = 10,
@ -1039,7 +1045,7 @@ 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;

View file

@ -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" {

View file

@ -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);

View file

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