tweak tests

This commit is contained in:
Emil Lerch 2026-05-23 11:25:39 -07:00
parent 04cf12d12e
commit 5e50c4eb6f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
14 changed files with 312 additions and 190 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."
- **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.
- 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

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

View file

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

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

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

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 "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);
}

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

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

View file

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

View file

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

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

@ -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.

View file

@ -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,