tweak tests
This commit is contained in:
parent
4bb29f1432
commit
4a5a3612e3
12 changed files with 266 additions and 144 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."
|
||||
- **Sample Inherited IRAs**: a separate category. Less protected than
|
||||
contributory IRAs in many states post-*Clark v. Rameker* (2014).
|
||||
Ideally tracked separately if relevant.
|
||||
- **HSAs**: typically protected if held in an HSA-qualified
|
||||
account; varies by state.
|
||||
- **Pensions / annuities**: usually protected, varies.
|
||||
- **Trusts**: depends on trust type; generally outside the scope
|
||||
of this TODO unless we get clean trust metadata.
|
||||
- **Brokerage / taxable**: never shielded. Always counts as
|
||||
exposed.
|
||||
- **Cash in personal bank accounts**: never shielded.
|
||||
|
||||
### Implementation options
|
||||
|
||||
Two extremes:
|
||||
|
||||
1. **Fully automatic** — infer shielding from `account_type` in
|
||||
`accounts.srf` (e.g., `401k`, `roth_ira`, `traditional_ira`)
|
||||
plus a static state-default table. Risk: state-by-state IRA
|
||||
law is genuinely complex, and a wrong default could produce
|
||||
a misleading number. The user would need to KNOW the table's
|
||||
assumptions.
|
||||
2. **Fully manual** — add a `shielded` boolean (or
|
||||
`shielded_fraction`: 0.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.
|
||||
- Sample Inherited IRAs flagged separately if we track that.
|
||||
|
||||
### Open questions
|
||||
|
||||
- Where should the state field live — `metadata.srf` (per
|
||||
portfolio file) or a global user config (one user, one state)?
|
||||
- Should we surface a "needed umbrella amount = net_exposure +
|
||||
[home equity, vehicles]" figure, or strictly stay liquid? The
|
||||
user asked about liquid assets specifically, so default to that
|
||||
but leave a note.
|
||||
- How to handle joint accounts where one spouse's lawsuit could
|
||||
reach the other's share. State-specific (community property
|
||||
vs separate property states). Probably out of scope for v1.
|
||||
|
||||
|
||||
|
||||
The following items are acknowledged but not prioritized. Listed here
|
||||
so they don't get lost; pick up opportunistically.
|
||||
|
|
|
|||
|
|
@ -420,18 +420,18 @@ fn mapToSortedBreakdown(
|
|||
test "parseAccountsFile" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\account::Emil Roth,tax_type::roth
|
||||
\\account::Joint Account,tax_type::taxable
|
||||
\\account::Fidelity Sample HSA,tax_type::hsa
|
||||
\\account::Sample Roth,tax_type::roth
|
||||
\\account::Sample Trust,tax_type::taxable
|
||||
\\account::Sample HSA,tax_type::hsa
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
var am = try parseAccountsFile(allocator, data);
|
||||
defer am.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 3), am.entries.len);
|
||||
try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Emil Roth"));
|
||||
try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Joint Account"));
|
||||
try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Fidelity Sample HSA"));
|
||||
try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Sample Roth"));
|
||||
try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Sample Trust"));
|
||||
try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Sample HSA"));
|
||||
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent"));
|
||||
}
|
||||
|
||||
|
|
@ -444,7 +444,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti
|
|||
// findByInstitutionAccount can't silently drop the link.
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\account::Mom Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123
|
||||
\\account::Sample Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123
|
||||
\\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::1234
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
|
|
@ -452,7 +452,7 @@ test "parseAccountsFile: institution + account_number round-trip via findByInsti
|
|||
defer am.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||||
try std.testing.expectEqualStrings("Mom Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
|
||||
try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
|
||||
try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?);
|
||||
// Wrong institution / wrong number → null.
|
||||
try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);
|
||||
|
|
|
|||
|
|
@ -1449,7 +1449,7 @@ test "snapshotToPoint: missing totals default to zero" {
|
|||
|
||||
test "snapshotToPoint: propagates accounts and tax_types" {
|
||||
const accts = [_]snapshot.AccountRow{
|
||||
.{ .kind = "account", .name = "Emil Roth", .value = 1000 },
|
||||
.{ .kind = "account", .name = "Sample Roth", .value = 1000 },
|
||||
.{ .kind = "account", .name = "Riley IRA", .value = 500 },
|
||||
};
|
||||
const taxes = [_]snapshot.TaxTypeRow{
|
||||
|
|
@ -1476,7 +1476,7 @@ test "snapshotToPoint: propagates accounts and tax_types" {
|
|||
defer p.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), p.accounts.len);
|
||||
try testing.expectEqualStrings("Emil Roth", p.accounts[0].name);
|
||||
try testing.expectEqualStrings("Sample Roth", p.accounts[0].name);
|
||||
try testing.expectEqual(@as(f64, 1000), p.accounts[0].value);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), p.tax_types.len);
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu
|
|||
var val_iter = std.mem.tokenizeAny(u8, lines[i + 1], &.{ ' ', '\t' });
|
||||
while (val_iter.next()) |tok| {
|
||||
if (parseDollarAmount(tok)) |v| {
|
||||
dollar_values.append(allocator, v) catch {};
|
||||
try dollar_values.append(allocator, v);
|
||||
}
|
||||
}
|
||||
if (dollar_values.items.len >= 2) {
|
||||
|
|
@ -289,15 +289,15 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu
|
|||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "parseTitle" {
|
||||
const t1 = parseTitle("\"Positions for account Joint Account ...1234 as of 10:47 AM ET, 2026/04/10\"");
|
||||
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"");
|
||||
try std.testing.expect(t1 != null);
|
||||
try std.testing.expectEqualStrings("Joint Account", t1.?.name);
|
||||
try std.testing.expectEqualStrings("Sample Trust", t1.?.name);
|
||||
try std.testing.expectEqualStrings("1234", t1.?.number);
|
||||
|
||||
const t2 = parseTitle("\"Positions for account Emil IRA ...7890 as of 3:00 PM ET, 2026/04/10\"");
|
||||
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"");
|
||||
try std.testing.expect(t2 != null);
|
||||
try std.testing.expectEqualStrings("Emil IRA", t2.?.name);
|
||||
try std.testing.expectEqualStrings("7890", t2.?.number);
|
||||
try std.testing.expectEqualStrings("Sample IRA", t2.?.name);
|
||||
try std.testing.expectEqualStrings("5678", t2.?.number);
|
||||
|
||||
try std.testing.expect(parseTitle("some random text") == null);
|
||||
}
|
||||
|
|
@ -316,7 +316,7 @@ test "splitCsvLine" {
|
|||
|
||||
test "parseCsv basic" {
|
||||
const csv =
|
||||
"\"Positions for account Joint Account ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
|
||||
"\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
|
||||
"\n" ++
|
||||
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
|
||||
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
|
||||
|
|
@ -327,7 +327,7 @@ test "parseCsv basic" {
|
|||
const parsed = try parseCsv(allocator, csv);
|
||||
defer allocator.free(parsed.positions);
|
||||
|
||||
try std.testing.expectEqualStrings("Joint Account", parsed.account_name);
|
||||
try std.testing.expectEqualStrings("Sample Trust", parsed.account_name);
|
||||
try std.testing.expectEqualStrings("1234", parsed.account_number);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
|
||||
|
|
@ -348,11 +348,11 @@ test "parseCsv basic" {
|
|||
|
||||
test "parseSummary basic" {
|
||||
const data =
|
||||
\\Emil Roth
|
||||
\\Account number ending in 5678 ...5678
|
||||
\\Sample Roth
|
||||
\\Account number ending in 1234 ...1234
|
||||
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
||||
\\Sample Inherited IRA
|
||||
\\Account number ending in 9012 ...9012
|
||||
\\Account number ending in 5678 ...5678
|
||||
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
|
|
@ -361,13 +361,13 @@ test "parseSummary basic" {
|
|||
|
||||
try std.testing.expectEqual(@as(usize, 2), accounts.len);
|
||||
|
||||
try std.testing.expectEqualStrings("Emil Roth", accounts[0].account_name);
|
||||
try std.testing.expectEqualStrings("5678", accounts[0].account_number);
|
||||
try std.testing.expectEqualStrings("Sample Roth", accounts[0].account_name);
|
||||
try std.testing.expectEqualStrings("1234", accounts[0].account_number);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 46.44), accounts[0].cash.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 227058.15), accounts[0].total_value.?, 0.01);
|
||||
|
||||
try std.testing.expectEqualStrings("Sample Inherited IRA", accounts[1].account_name);
|
||||
try std.testing.expectEqualStrings("9012", accounts[1].account_number);
|
||||
try std.testing.expectEqualStrings("5678", accounts[1].account_number);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2461.82), accounts[1].cash.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 167544.08), accounts[1].total_value.?, 0.01);
|
||||
}
|
||||
|
|
@ -375,12 +375,12 @@ test "parseSummary basic" {
|
|||
test "parseSummary tolerates missing headers and extra blank lines" {
|
||||
const data =
|
||||
\\
|
||||
\\Joint Account
|
||||
\\Sample Trust
|
||||
\\Account number ending in 1234 ...1234
|
||||
\\Type Brokerage $8,271.12 $849,087.12 +$20,488.80 +2.47%
|
||||
\\
|
||||
\\Tax Loss
|
||||
\\Account number ending in 2345 ...2345
|
||||
\\Account number ending in 5678 ...5678
|
||||
\\$4,654.15 $488,481.18 +$1,686.91 +0.35%
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
|
|
@ -388,7 +388,7 @@ test "parseSummary tolerates missing headers and extra blank lines" {
|
|||
defer allocator.free(accounts);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), accounts.len);
|
||||
try std.testing.expectEqualStrings("Joint Account", accounts[0].account_name);
|
||||
try std.testing.expectEqualStrings("Sample Trust", accounts[0].account_name);
|
||||
try std.testing.expectEqualStrings("1234", accounts[0].account_number);
|
||||
|
||||
// Second account has no "Type" prefix — parser still finds dollar amounts
|
||||
|
|
@ -399,8 +399,8 @@ test "parseSummary tolerates missing headers and extra blank lines" {
|
|||
|
||||
test "parseSummary skips summary footer" {
|
||||
const data =
|
||||
\\Mom
|
||||
\\Account number ending in 152 ...152
|
||||
\\Sample Account
|
||||
\\Account number ending in 1234 ...1234
|
||||
\\Type Brokerage $3,492.85 $161,676.14 +$749.40 +0.47%
|
||||
\\Investment Total
|
||||
\\$22,070.35
|
||||
|
|
@ -413,7 +413,7 @@ test "parseSummary skips summary footer" {
|
|||
defer allocator.free(accounts);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), accounts.len);
|
||||
try std.testing.expectEqualStrings("Mom", accounts[0].account_name);
|
||||
try std.testing.expectEqualStrings("Sample Account", accounts[0].account_name);
|
||||
}
|
||||
|
||||
test "parseSummary no accounts" {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
//! fallback; the format itself doesn't tag cash distinctly.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const types = @import("types.zig");
|
||||
const analysis = @import("../analytics/analysis.zig");
|
||||
|
|
@ -255,8 +256,8 @@ pub const Resolved = struct {
|
|||
/// path (without extension) and try to match it against the
|
||||
/// `account::` field of each WF entry, allowing for
|
||||
/// case-insensitive substring overlap on the trailing
|
||||
/// `*NNNN` digits. Filename "Sample_IRA_3522.txt" matches
|
||||
/// "Sample IRA *4567".
|
||||
/// `*NNNN` digits. Filename "Sample_IRA_1234.txt" matches
|
||||
/// "Sample IRA *1234".
|
||||
/// 3. **Single-WF-entry fallback.** If accounts.srf has exactly
|
||||
/// one `institution::wells_fargo` entry, use it. Helpful for
|
||||
/// users with one WF account; harmless when there are many
|
||||
|
|
@ -286,14 +287,16 @@ pub fn resolveAccount(
|
|||
return resolutionFor(io, e);
|
||||
}
|
||||
}
|
||||
var stderr_buf: [4096]u8 = undefined;
|
||||
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
try sw.interface.print(
|
||||
"Error: --account '{s}' did not match any `institution::wells_fargo` entry in accounts.srf.\n",
|
||||
.{name},
|
||||
);
|
||||
try printEntries(&sw.interface, account_map);
|
||||
try sw.interface.flush();
|
||||
if (!builtin.is_test) {
|
||||
var stderr_buf: [4096]u8 = undefined;
|
||||
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
try sw.interface.print(
|
||||
"Error: --account '{s}' did not match any `institution::wells_fargo` entry in accounts.srf.\n",
|
||||
.{name},
|
||||
);
|
||||
try printEntries(&sw.interface, account_map);
|
||||
try sw.interface.flush();
|
||||
}
|
||||
return error.UnknownAccount;
|
||||
}
|
||||
|
||||
|
|
@ -336,24 +339,26 @@ pub fn resolveAccount(
|
|||
if (wf_count == 1) return resolutionFor(io, single.?);
|
||||
|
||||
// Couldn't pick. Print enumerated guidance.
|
||||
var stderr_buf: [4096]u8 = undefined;
|
||||
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
if (wf_count == 0) {
|
||||
try sw.interface.print(
|
||||
"Error: no `institution::wells_fargo` entries found in accounts.srf.\n" ++
|
||||
" Add one (e.g. `account::Sample IRA *4567,tax_type::roth,institution::wells_fargo,account_number::4567`)\n" ++
|
||||
" and rerun the import.\n",
|
||||
.{},
|
||||
);
|
||||
} else {
|
||||
try sw.interface.print(
|
||||
"Error: {d} Wells Fargo accounts in accounts.srf; cannot pick one automatically.\n" ++
|
||||
" Pass --account NAME to disambiguate. Candidates:\n",
|
||||
.{wf_count},
|
||||
);
|
||||
try printEntries(&sw.interface, account_map);
|
||||
if (!builtin.is_test) {
|
||||
var stderr_buf: [4096]u8 = undefined;
|
||||
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
if (wf_count == 0) {
|
||||
try sw.interface.print(
|
||||
"Error: no `institution::wells_fargo` entries found in accounts.srf.\n" ++
|
||||
" Add one (e.g. `account::Sample IRA *1234,tax_type::roth,institution::wells_fargo,account_number::1234`)\n" ++
|
||||
" and rerun the import.\n",
|
||||
.{},
|
||||
);
|
||||
} else {
|
||||
try sw.interface.print(
|
||||
"Error: {d} Wells Fargo accounts in accounts.srf; cannot pick one automatically.\n" ++
|
||||
" Pass --account NAME to disambiguate. Candidates:\n",
|
||||
.{wf_count},
|
||||
);
|
||||
try printEntries(&sw.interface, account_map);
|
||||
}
|
||||
try sw.interface.flush();
|
||||
}
|
||||
try sw.interface.flush();
|
||||
return error.AmbiguousWellsFargoAccount;
|
||||
}
|
||||
|
||||
|
|
@ -382,14 +387,16 @@ pub fn applyAccountToPositions(
|
|||
/// downstream `findByInstitutionAccount` lookup keys on it.
|
||||
fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved {
|
||||
const num = entry.account_number orelse {
|
||||
var stderr_buf: [512]u8 = undefined;
|
||||
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
try sw.interface.print(
|
||||
"Error: WF account '{s}' has no `account_number::` field in accounts.srf.\n" ++
|
||||
" Add one (the trailing digits after `*` work well, e.g. `account_number::4567`).\n",
|
||||
.{entry.account},
|
||||
);
|
||||
try sw.interface.flush();
|
||||
if (!builtin.is_test) {
|
||||
var stderr_buf: [512]u8 = undefined;
|
||||
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
try sw.interface.print(
|
||||
"Error: WF account '{s}' has no `account_number::` field in accounts.srf.\n" ++
|
||||
" Add one (the trailing digits after `*` work well, e.g. `account_number::1234`).\n",
|
||||
.{entry.account},
|
||||
);
|
||||
try sw.interface.flush();
|
||||
}
|
||||
return error.UnknownAccount;
|
||||
};
|
||||
return .{ .account_number = num, .account_name = entry.account };
|
||||
|
|
@ -402,12 +409,12 @@ fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved {
|
|||
/// underscores and spaces treated as equivalent.
|
||||
///
|
||||
/// Examples:
|
||||
/// filenameMatchesAccount("Sample_IRA_3522", "Sample IRA *4567") → true
|
||||
/// filenameMatchesAccount("eliz-ira-3522", "Sample IRA *4567") → true (digits match)
|
||||
/// filenameMatchesAccount("portfolio_other", "Sample IRA *4567") → false
|
||||
/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234") → true
|
||||
/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234") → true (digits match)
|
||||
/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234") → false
|
||||
fn filenameMatchesAccount(filename: []const u8, account_name: []const u8) bool {
|
||||
// Extract the trailing digit run from the account name.
|
||||
// "Sample IRA *4567" → "4567".
|
||||
// "Sample IRA *1234" → "1234".
|
||||
var digits_start: usize = account_name.len;
|
||||
while (digits_start > 0) {
|
||||
const c = account_name[digits_start - 1];
|
||||
|
|
@ -761,12 +768,12 @@ fn testAccountMap(allocator: std.mem.Allocator, entries: []const analysis.Accoun
|
|||
test "filenameMatchesAccount: trailing-digit anchor wins" {
|
||||
// Strongest signal — WF account suffixes are unique within
|
||||
// a household, so a digit-run match is unambiguous.
|
||||
try testing.expect(filenameMatchesAccount("Sample_IRA_3522", "Sample IRA *4567"));
|
||||
try testing.expect(filenameMatchesAccount("3522.txt", "Sample IRA *4567"));
|
||||
try testing.expect(filenameMatchesAccount("eliz-ira-3522", "Sample IRA *4567"));
|
||||
try testing.expect(filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234"));
|
||||
try testing.expect(filenameMatchesAccount("1234.txt", "Sample IRA *1234"));
|
||||
try testing.expect(filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234"));
|
||||
// Different digit suffix → no match.
|
||||
try testing.expect(!filenameMatchesAccount("Sample_IRA_7891", "Sample IRA *4567"));
|
||||
try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *4567"));
|
||||
try testing.expect(!filenameMatchesAccount("Sample_IRA_5678", "Sample IRA *1234"));
|
||||
try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *1234"));
|
||||
}
|
||||
|
||||
test "filenameMatchesAccount: alpha-only fallback when account has no digit suffix" {
|
||||
|
|
@ -799,20 +806,20 @@ test "alphaRunsContained: every alphanumeric run from account appears in order"
|
|||
test "resolveAccount: explicit override matches a WF entry" {
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
|
||||
.{ .account = "Sample Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" },
|
||||
.{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
|
||||
.{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
const r = try resolveAccount(testing.io, account_map, "anything.txt", "Sample IRA *4567");
|
||||
try testing.expectEqualStrings("4567", r.account_number);
|
||||
try testing.expectEqualStrings("Sample IRA *4567", r.account_name);
|
||||
const r = try resolveAccount(testing.io, account_map, "anything.txt", "Sample IRA *1234");
|
||||
try testing.expectEqualStrings("1234", r.account_number);
|
||||
try testing.expectEqualStrings("Sample IRA *1234", r.account_name);
|
||||
}
|
||||
|
||||
test "resolveAccount: explicit override that doesn't match → UnknownAccount" {
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
|
||||
.{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
|
|
@ -822,33 +829,33 @@ test "resolveAccount: explicit override that doesn't match → UnknownAccount" {
|
|||
test "resolveAccount: filename inference picks the right entry from multiple WF accounts" {
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
|
||||
.{ .account = "Sample Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" },
|
||||
.{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
|
||||
.{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
const r = try resolveAccount(testing.io, account_map, "/path/to/Sample_IRA_3522.txt", null);
|
||||
try testing.expectEqualStrings("4567", r.account_number);
|
||||
const r = try resolveAccount(testing.io, account_map, "/path/to/Sample_IRA_1234.txt", null);
|
||||
try testing.expectEqualStrings("1234", r.account_number);
|
||||
}
|
||||
|
||||
test "resolveAccount: single-WF-entry fallback when filename has no signal" {
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
|
||||
.{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
|
||||
// Non-WF entry shouldn't interfere.
|
||||
.{ .account = "Mom Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
||||
.{ .account = "Sample Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
const r = try resolveAccount(testing.io, account_map, "unrelated_filename.txt", null);
|
||||
try testing.expectEqualStrings("4567", r.account_number);
|
||||
try testing.expectEqualStrings("1234", r.account_number);
|
||||
}
|
||||
|
||||
test "resolveAccount: ambiguous when 2+ WF entries and no signal" {
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
|
||||
.{ .account = "Sample Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" },
|
||||
.{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
|
||||
.{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
|
|
@ -858,7 +865,7 @@ test "resolveAccount: ambiguous when 2+ WF entries and no signal" {
|
|||
test "resolveAccount: zero WF entries → AmbiguousWellsFargoAccount with helpful message" {
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Mom Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
||||
.{ .account = "Sample Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
|
|
@ -884,7 +891,7 @@ test "resolveAccount: WF entry without account_number → UnknownAccount" {
|
|||
test "applyAccountToPositions: patches every position's account fields" {
|
||||
const allocator = testing.allocator;
|
||||
var account_map = try testAccountMap(allocator, &.{
|
||||
.{ .account = "Sample IRA *4567", .tax_type = .roth, .institution = "wells_fargo", .account_number = "4567" },
|
||||
.{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
|
|
@ -893,9 +900,9 @@ test "applyAccountToPositions: patches every position's account fields" {
|
|||
.{ .account_number = "", .account_name = "", .symbol = "AAPL", .description = "", .quantity = 5, .current_value = 1000, .cost_basis = 750, .is_cash = false },
|
||||
};
|
||||
|
||||
try applyAccountToPositions(testing.io, account_map, "Sample_IRA_3522.txt", null, &positions);
|
||||
try testing.expectEqualStrings("4567", positions[0].account_number);
|
||||
try testing.expectEqualStrings("Sample IRA *4567", positions[0].account_name);
|
||||
try testing.expectEqualStrings("4567", positions[1].account_number);
|
||||
try testing.expectEqualStrings("Sample IRA *4567", positions[1].account_name);
|
||||
try applyAccountToPositions(testing.io, account_map, "Sample_IRA_1234.txt", null, &positions);
|
||||
try testing.expectEqualStrings("1234", positions[0].account_number);
|
||||
try testing.expectEqualStrings("Sample IRA *1234", positions[0].account_name);
|
||||
try testing.expectEqualStrings("1234", positions[1].account_number);
|
||||
try testing.expectEqualStrings("Sample IRA *1234", positions[1].account_name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2174,7 +2174,7 @@ test "option delta tracking in compareAccounts" {
|
|||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 6.68,
|
||||
.multiplier = 100,
|
||||
.account = "Emil IRA",
|
||||
.account = "Sample IRA",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
|
@ -2193,10 +2193,10 @@ test "option delta tracking in compareAccounts" {
|
|||
},
|
||||
};
|
||||
|
||||
// Account map: map schwab account 1234 -> portfolio "Emil IRA"
|
||||
// Account map: map schwab account 1234 -> portfolio "Sample IRA"
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Emil IRA",
|
||||
.account = "Sample IRA",
|
||||
.tax_type = .roth,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
|
|
@ -2593,7 +2593,7 @@ test "printLargeLotWarning: cents are preserved in template" {
|
|||
var writer = std.Io.Writer.fixed(&buf);
|
||||
|
||||
const lot: contributions.UnmatchedLargeLot = .{
|
||||
.account = "Joint Account",
|
||||
.account = "Sample Trust",
|
||||
.symbol = "",
|
||||
.security_type = .cash,
|
||||
.value = 73_158.33,
|
||||
|
|
|
|||
|
|
@ -2885,7 +2885,7 @@ test "computeReport: matured CD with maturity_date <= today" {
|
|||
defer prices.deinit();
|
||||
|
||||
const before = [_]Lot{
|
||||
.{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Emil IRA", .maturity_date = Date.fromYmd(2026, 4, 17) },
|
||||
.{ .symbol = "CD1", .shares = 58000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.0, .security_type = .cd, .account = "Sample IRA", .maturity_date = Date.fromYmd(2026, 4, 17) },
|
||||
};
|
||||
const after = [_]Lot{};
|
||||
|
||||
|
|
@ -4539,7 +4539,7 @@ test "collectUnmatchedLargeLots: matched via transfer log is silent" {
|
|||
|
||||
test "collectUnmatchedLargeLots: cash-destination matched is silent" {
|
||||
// Regression for the user-visible bug: a $73,158.33 cash lot on
|
||||
// Joint Account funded by a transfer record dated 2026-05-20 was
|
||||
// Sample Trust funded by a transfer record dated 2026-05-20 was
|
||||
// surfacing in audit's "Large new lots — confirm source" because
|
||||
// the cash matcher doesn't flip the original `new_cash` Change's
|
||||
// kind (it draws from `cash_attributed_by_account` instead).
|
||||
|
|
@ -4553,12 +4553,12 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" {
|
|||
|
||||
const before = [_]Lot{};
|
||||
const after = [_]Lot{
|
||||
.{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint Account" },
|
||||
.{ .security_type = .cash, .shares = 73158.33, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
|
||||
};
|
||||
|
||||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Fidelity Sample,to::Joint Account,dest_lot::cash
|
||||
\\transfer::2026-05-20,type::cash,amount:num:73158.33,from::Sample Source,to::Sample Trust,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
|
||||
|
|
@ -4579,7 +4579,7 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" {
|
|||
};
|
||||
try std.testing.expect(saw_new_cash);
|
||||
try std.testing.expect(saw_synthetic_transfer);
|
||||
const attributed = report.cash_attributed_by_account.get("Joint Account") orelse 0;
|
||||
const attributed = report.cash_attributed_by_account.get("Sample Trust") orelse 0;
|
||||
try std.testing.expectEqual(@as(f64, 73158.33), attributed);
|
||||
|
||||
// The audit filter must subtract the attribution and stay quiet.
|
||||
|
|
@ -4600,12 +4600,12 @@ test "collectUnmatchedLargeLots: cash-destination partial match surfaces residua
|
|||
|
||||
const before = [_]Lot{};
|
||||
const after = [_]Lot{
|
||||
.{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint Account" },
|
||||
.{ .security_type = .cash, .shares = 50000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
|
||||
};
|
||||
|
||||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-20,type::cash,amount:num:30000,from::Fidelity Sample,to::Joint Account,dest_lot::cash
|
||||
\\transfer::2026-05-20,type::cash,amount:num:30000,from::Sample Source,to::Sample Trust,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
|
||||
|
|
@ -4629,12 +4629,12 @@ test "collectUnmatchedLargeLots: cash-destination partial below threshold is sil
|
|||
|
||||
const before = [_]Lot{};
|
||||
const after = [_]Lot{
|
||||
.{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Joint Account" },
|
||||
.{ .security_type = .cash, .shares = 15000.0, .open_date = Date.fromYmd(2026, 5, 20), .open_price = 1.0, .account = "Sample Trust" },
|
||||
};
|
||||
|
||||
const tlog = try transaction_log.parseTransactionLogFile(allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-20,type::cash,amount:num:10000,from::Fidelity Sample,to::Joint Account,dest_lot::cash
|
||||
\\transfer::2026-05-20,type::cash,amount:num:10000,from::Sample Source,to::Sample Trust,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -862,10 +862,10 @@ test "resolvePatterns: two patterns matching same dir union-merge" {
|
|||
|
||||
const main_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_main.srf" });
|
||||
defer testing.allocator.free(main_path);
|
||||
const mom_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_other.srf" });
|
||||
defer testing.allocator.free(mom_path);
|
||||
const other_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_other.srf" });
|
||||
defer testing.allocator.free(other_path);
|
||||
|
||||
const patterns = [_][]const u8{ main_path, mom_path };
|
||||
const patterns = [_][]const u8{ main_path, other_path };
|
||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
|
||||
defer result.deinit();
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
//! zfin -p portfolio_other.srf import --fidelity ~/Downloads/positions.csv
|
||||
//! # 3. Inspect changes; commit when satisfied.
|
||||
//! git diff portfolio_other.srf
|
||||
//! git add portfolio_other.srf && git commit -m "Update mom's portfolio"
|
||||
//! git add portfolio_other.srf && git commit -m "Update managed portfolio"
|
||||
//! ```
|
||||
//!
|
||||
//! ## Safety
|
||||
|
|
@ -112,6 +112,7 @@
|
|||
//! lots with broken account names.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
|
|
@ -721,26 +722,31 @@ fn synthesizeLots(
|
|||
}
|
||||
}
|
||||
if (unmapped.items.len > 0) {
|
||||
var stderr_buf: [4096]u8 = undefined;
|
||||
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
try stderr_writer.interface.print(
|
||||
"Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n",
|
||||
.{
|
||||
unmapped.items.len,
|
||||
if (unmapped.items.len == 1) "" else "s",
|
||||
institution,
|
||||
if (unmapped.items.len == 1) "is" else "are",
|
||||
},
|
||||
);
|
||||
for (unmapped.items) |num| {
|
||||
try stderr_writer.interface.print(" - {s}\n", .{num});
|
||||
// Skip the human-readable error message under tests to keep
|
||||
// test output clean; the returned error is what the test
|
||||
// assertions rely on.
|
||||
if (!builtin.is_test) {
|
||||
var stderr_buf: [4096]u8 = undefined;
|
||||
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
|
||||
try stderr_writer.interface.print(
|
||||
"Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n",
|
||||
.{
|
||||
unmapped.items.len,
|
||||
if (unmapped.items.len == 1) "" else "s",
|
||||
institution,
|
||||
if (unmapped.items.len == 1) "is" else "are",
|
||||
},
|
||||
);
|
||||
for (unmapped.items) |num| {
|
||||
try stderr_writer.interface.print(" - {s}\n", .{num});
|
||||
}
|
||||
try stderr_writer.interface.print(
|
||||
"\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++
|
||||
"`account_number::` value, then rerun the import.\n",
|
||||
.{institution},
|
||||
);
|
||||
try stderr_writer.interface.flush();
|
||||
}
|
||||
try stderr_writer.interface.print(
|
||||
"\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++
|
||||
"`account_number::` value, then rerun the import.\n",
|
||||
.{institution},
|
||||
);
|
||||
try stderr_writer.interface.flush();
|
||||
return error.UnmappedAccount;
|
||||
}
|
||||
|
||||
|
|
@ -896,7 +902,7 @@ test "synthesizeLots: missing cost_basis falls back to current_value" {
|
|||
const positions = [_]BrokeragePosition{
|
||||
.{
|
||||
.account_number = "1234",
|
||||
.account_name = "Joint Account",
|
||||
.account_name = "Sample Trust",
|
||||
.symbol = "MSFT",
|
||||
.description = "MICROSOFT",
|
||||
.quantity = 10,
|
||||
|
|
@ -1039,7 +1045,7 @@ test "synthesizeLots: unmapped account_number fails with UnmappedAccount" {
|
|||
}
|
||||
|
||||
test "synthesizeLots: institution mismatch counts as unmapped" {
|
||||
// Account number 716 is registered for schwab; an entry coming
|
||||
// Account number 1234 is registered for schwab; an entry coming
|
||||
// in as fidelity must NOT match it. Catches the case where the
|
||||
// user has the same trailing digits at two brokerages.
|
||||
const allocator = testing.allocator;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ test "TransferRecord.eql: identical records" {
|
|||
.transfer = Date.fromYmd(2026, 5, 20),
|
||||
.type = .cash,
|
||||
.amount = 73158.0,
|
||||
.from = "Fidelity Sample",
|
||||
.from = "Sample Source",
|
||||
.to = "Sample Trust",
|
||||
.dest_lot = .cash,
|
||||
.note = null,
|
||||
|
|
@ -412,7 +412,7 @@ test "TransferRecord.eql: identical records" {
|
|||
.transfer = Date.fromYmd(2026, 5, 20),
|
||||
.type = .cash,
|
||||
.amount = 73158.0,
|
||||
.from = "Fidelity Sample",
|
||||
.from = "Sample Source",
|
||||
.to = "Sample Trust",
|
||||
.dest_lot = .cash,
|
||||
.note = null,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue