tweak tests
This commit is contained in:
parent
04cf12d12e
commit
5e50c4eb6f
14 changed files with 312 additions and 190 deletions
111
TODO.md
111
TODO.md
|
|
@ -627,7 +627,116 @@ Investigate by replacing the `print` with a manual `print` +
|
|||
`flush` to see if it's a buffer-not-flushed issue, or by
|
||||
serializing a known-good fixture in isolation.
|
||||
|
||||
## Low-priority items
|
||||
## Analysis: umbrella-insurance exposure indicator — priority MEDIUM
|
||||
|
||||
In the `analysis` command and TUI tab, surface how much of the
|
||||
liquid portfolio is *exposed to lawsuit / creditor risk* and
|
||||
therefore should be covered by umbrella insurance. The number
|
||||
the user actually wants is "how much could be lost if I'm sued
|
||||
and lose" — which is liquid assets minus the legally-shielded
|
||||
buckets.
|
||||
|
||||
### Shielding rules (US, broad strokes)
|
||||
|
||||
- **401(k) and other ERISA-qualified employer plans**: shielded
|
||||
by ERISA, federal-law-level protection. Effectively untouchable
|
||||
in lawsuits. Always exclude from the umbrella-coverage figure.
|
||||
- **IRAs (traditional + Roth)**: protection is **state-by-state**.
|
||||
- Federal bankruptcy protection up to ~$1.5M (BAPCPA, indexed)
|
||||
applies in bankruptcy court only.
|
||||
- Outside bankruptcy (i.e., civil judgments), it's state law.
|
||||
Some states give full protection (e.g., TX, FL); some give
|
||||
none; many give partial / "reasonably necessary for support."
|
||||
- **Inherited IRAs**: a separate category. Less protected than
|
||||
contributory IRAs in many states post-*Clark v. Rameker* (2014).
|
||||
Ideally tracked separately if relevant.
|
||||
- **HSAs**: typically protected if held in an HSA-qualified
|
||||
account; varies by state.
|
||||
- **Pensions / annuities**: usually protected, varies.
|
||||
- **Trusts**: depends on trust type; generally outside the scope
|
||||
of this TODO unless we get clean trust metadata.
|
||||
- **Brokerage / taxable**: never shielded. Always counts as
|
||||
exposed.
|
||||
- **Cash in personal bank accounts**: never shielded.
|
||||
|
||||
### Implementation options
|
||||
|
||||
Two extremes:
|
||||
|
||||
1. **Fully automatic** — infer shielding from `account_type` in
|
||||
`accounts.srf` (e.g., `401k`, `roth_ira`, `traditional_ira`)
|
||||
plus a static state-default table. Risk: state-by-state IRA
|
||||
law is genuinely complex, and a wrong default could produce
|
||||
a misleading number. The user would need to KNOW the table's
|
||||
assumptions.
|
||||
2. **Fully manual** — add a `shielded` boolean (or
|
||||
`shielded_fraction`: 0.0–1.0) to each account in `accounts.srf`.
|
||||
User decides per account. Most accurate, more upfront work.
|
||||
|
||||
### Recommended hybrid
|
||||
|
||||
- Add an `account_type` field if not already present (the
|
||||
metadata for tax-treatment is likely already there for
|
||||
contributions tracking). Map types to a default shielding:
|
||||
- `401k`, `403b`, `457`, `pension` → `shielded = true`
|
||||
- `roth_401k`, `roth_403b` → `shielded = true`
|
||||
- `traditional_ira`, `roth_ira`, `sep_ira`, `simple_ira` →
|
||||
`shielded = depends_on_state` (unknown without state info)
|
||||
- `hsa` → `shielded = true` (with caveat)
|
||||
- `brokerage`, `bank`, `joint`, `trust` → `shielded = false`
|
||||
- Add an optional per-account `shielded` override in
|
||||
`accounts.srf` (e.g. `shielded::true` / `shielded::false` /
|
||||
`shielded::partial`) that wins over the default. This is the
|
||||
escape hatch for "my state of residence treats IRAs as
|
||||
shielded" or "this trust isn't protected."
|
||||
- Add a `state` field at the user level (perhaps
|
||||
`metadata.srf` or a config), with a built-in lookup table of
|
||||
state IRA protection (`full`, `partial`, `none`) that
|
||||
populates the default for IRA-type accounts when no override
|
||||
is set.
|
||||
|
||||
### Output
|
||||
|
||||
In the analysis tab / command, add a section like:
|
||||
|
||||
```
|
||||
Umbrella exposure
|
||||
Total liquid value: $X,XXX,XXX
|
||||
Shielded (401k/ERISA): $XXX,XXX
|
||||
Shielded (IRA, NY default = partial): $XXX,XXX (per state default)
|
||||
Shielded (override): $XXX,XXX
|
||||
Net exposure: $X,XXX,XXX ← umbrella target
|
||||
```
|
||||
|
||||
Show it both as an absolute dollar amount and as a percent of
|
||||
liquid total. Include the assumption text (state, default for
|
||||
that state) inline so the user knows what the number means and
|
||||
when it might be wrong.
|
||||
|
||||
### Tests
|
||||
|
||||
- Default shielding for each `account_type`.
|
||||
- Override wins over default.
|
||||
- State table consistency (no missing states).
|
||||
- "I don't know my state" path should refuse to guess for IRAs
|
||||
and instead report the IRA-shielded portion as `unknown`,
|
||||
forcing the user to choose between "treat as shielded" /
|
||||
"treat as exposed" via override.
|
||||
- Inherited IRAs flagged separately if we track that.
|
||||
|
||||
### Open questions
|
||||
|
||||
- Where should the state field live — `metadata.srf` (per
|
||||
portfolio file) or a global user config (one user, one state)?
|
||||
- Should we surface a "needed umbrella amount = net_exposure +
|
||||
[home equity, vehicles]" figure, or strictly stay liquid? The
|
||||
user asked about liquid assets specifically, so default to that
|
||||
but leave a note.
|
||||
- How to handle joint accounts where one spouse's lawsuit could
|
||||
reach the other's share. State-specific (community property
|
||||
vs separate property states). Probably out of scope for v1.
|
||||
|
||||
|
||||
|
||||
The following items are acknowledged but not prioritized. Listed here
|
||||
so they don't get lost; pick up opportunistically.
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
\\
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue