initial wells fargo parsing implementation
This commit is contained in:
parent
21279c1aeb
commit
809146b111
4 changed files with 1053 additions and 25 deletions
|
|
@ -437,6 +437,30 @@ test "parseAccountsFile" {
|
|||
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent"));
|
||||
}
|
||||
|
||||
test "parseAccountsFile: institution + account_number round-trip via findByInstitutionAccount" {
|
||||
// The import command's WF resolver, the audit reconciler's
|
||||
// schwab/fidelity match logic, and the snapshot writer all
|
||||
// depend on `findByInstitutionAccount` finding entries that
|
||||
// were parsed from `accounts.srf`. Pin the round-trip so a
|
||||
// future change to either parseAccountsFile or
|
||||
// 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
|
||||
;
|
||||
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").?);
|
||||
// Wrong institution / wrong number → null.
|
||||
try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);
|
||||
try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
|
||||
}
|
||||
|
||||
test "parseAccountsFile: cash_is_contribution default false, opt-in true" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
|
|
|
|||
901
src/brokerage/wells_fargo.zig
Normal file
901
src/brokerage/wells_fargo.zig
Normal file
|
|
@ -0,0 +1,901 @@
|
|||
//! Wells Fargo paste parser.
|
||||
//!
|
||||
//! Wells Fargo's brokerage portal doesn't offer a clean CSV export
|
||||
//! for positions, so the user copies the rendered HTML table and
|
||||
//! pastes the result into a file. The paste is a tab-separated
|
||||
//! multi-line layout, one record per holding plus a (sometimes
|
||||
//! present) totals block at the end.
|
||||
//!
|
||||
//! ## Format
|
||||
//!
|
||||
//! Header preamble (optional — present when the user's paste
|
||||
//! includes the column headers, absent when they paste only the
|
||||
//! rows). When present, it spans the first ~12 lines and starts
|
||||
//! with `Symbol/Description`. The parser scans for the first
|
||||
//! `<SYMBOL> , popup` line as the data anchor and ignores
|
||||
//! everything before it.
|
||||
//!
|
||||
//! Each position record looks like:
|
||||
//!
|
||||
//! ```
|
||||
//! GSLC , popup ← record anchor: <SYMBOL> , popup
|
||||
//! GOLDMAN ACTIVEBETA ETF ← description
|
||||
//! Multiple(3) or MM/DD/YYYY ← lot count or single-buy date
|
||||
//! 906 ← shares (may have commas)
|
||||
//! @ $129.97 ← average cost basis per share
|
||||
//! <blank-tab>
|
||||
//! $140.90 ← last price
|
||||
//! +$0.31 ← day change ($)
|
||||
//! <blank-tab>
|
||||
//! $127,655.40 ← market value
|
||||
//! +$280.86 (+0.22%) ← day change ($, %)
|
||||
//! <blank-tab>
|
||||
//! +$9,906.42 ← unrealized gain/loss ($)
|
||||
//! +8.41% ← unrealized gain/loss (%)
|
||||
//! <blank-tab>
|
||||
//! $1,203.17 ← est. annual income
|
||||
//! <blank-tab> ← record separator
|
||||
//! ```
|
||||
//!
|
||||
//! Footer (optional — sometimes a totals block appears, sometimes
|
||||
//! the paste ends after the last record's est-annual-income).
|
||||
//! The parser stops on a line that begins with a known total
|
||||
//! sentinel ("ETFs Total", "Total", etc.) OR on EOF.
|
||||
//!
|
||||
//! ## Limitations
|
||||
//!
|
||||
//! 1. Format is layout-fragile — if WF changes the table structure,
|
||||
//! this parser breaks. We re-anchor on `<SYMBOL> , popup` per
|
||||
//! record, which gives some robustness against extra blank
|
||||
//! lines or stray whitespace, but column reordering would
|
||||
//! require updating the field offsets below.
|
||||
//!
|
||||
//! 2. WF pastes don't carry an account identifier; the import
|
||||
//! command resolves the account separately (filename inference
|
||||
//! + `--account` override + accounts.srf lookup).
|
||||
//!
|
||||
//! 3. Cost basis is computed from `shares × avg_cost`. WF doesn't
|
||||
//! print "total cost basis" alongside; the multiplication is
|
||||
//! a re-derivation from the per-share avg.
|
||||
//!
|
||||
//! 4. No cash classification. Wells Fargo positions of cash /
|
||||
//! money-market funds may need the standard `isMoneyMarketSymbol`
|
||||
//! fallback; the format itself doesn't tag cash distinctly.
|
||||
|
||||
const std = @import("std");
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const types = @import("types.zig");
|
||||
const analysis = @import("../analytics/analysis.zig");
|
||||
|
||||
const BrokeragePosition = types.BrokeragePosition;
|
||||
const parseDollarAmount = types.parseDollarAmount;
|
||||
|
||||
/// Institution name used for `accounts.srf` lookups
|
||||
/// (`institution::wells_fargo`). Held as a constant so the parser
|
||||
/// and the resolver don't drift on the spelling.
|
||||
pub const institution = "wells_fargo";
|
||||
|
||||
/// Parse a Wells Fargo paste into BrokeragePosition slices.
|
||||
///
|
||||
/// All string fields in the returned positions are slices into
|
||||
/// `data` (caller must keep `data` alive). The returned slice
|
||||
/// itself is heap-allocated against `allocator`.
|
||||
///
|
||||
/// `account_number` and `account_name` are left as empty strings
|
||||
/// — WF pastes don't carry account identity. The import command
|
||||
/// fills these in from filename inference / accounts.srf lookup.
|
||||
pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
|
||||
var positions = std.ArrayList(BrokeragePosition).empty;
|
||||
errdefer positions.deinit(allocator);
|
||||
|
||||
var lines = std.mem.splitScalar(u8, data, '\n');
|
||||
// Buffer up to N lookahead lines so the parser can slide a
|
||||
// window over the per-record layout without juggling iterator
|
||||
// state. Per-record this is at most ~16 lines.
|
||||
var staged: std.ArrayList([]const u8) = .empty;
|
||||
defer staged.deinit(allocator);
|
||||
while (lines.next()) |raw| {
|
||||
const trimmed = std.mem.trim(u8, raw, &.{ ' ', '\t', '\r' });
|
||||
try staged.append(allocator, trimmed);
|
||||
}
|
||||
|
||||
// Locate each record by scanning for `<SYMBOL> , popup`. WF
|
||||
// emits this exact suffix as part of the symbol column's
|
||||
// hover-popup affordance; it's a very stable record anchor.
|
||||
var i: usize = 0;
|
||||
while (i < staged.items.len) : (i += 1) {
|
||||
const line = staged.items[i];
|
||||
// Stop on totals footer (`ETFs Total`, `Total`, etc).
|
||||
if (isTotalLine(line)) break;
|
||||
if (!isPopupAnchor(line)) continue;
|
||||
|
||||
const symbol = popupSymbol(line) orelse continue;
|
||||
|
||||
// Walk forward collecting the rest of the fields. Each
|
||||
// field-find returns the index it consumed up to, or
|
||||
// null if the record is truncated. We tolerate stray
|
||||
// blank lines between fields because WF pastes
|
||||
// sometimes carry extra whitespace.
|
||||
var cur = i + 1;
|
||||
|
||||
// Description: first non-empty line after the anchor.
|
||||
const description = nextNonEmpty(staged.items, &cur) orelse break;
|
||||
|
||||
// Trade-date column: either `Multiple(N)` or `MM/DD/YYYY`.
|
||||
// We don't use the value but consuming it advances `cur`
|
||||
// to the shares line. Single-date format pastes have an
|
||||
// extra blank line between the description and the date,
|
||||
// so step past blanks.
|
||||
_ = nextNonEmpty(staged.items, &cur) orelse break;
|
||||
|
||||
// Shares: integer with optional thousands commas, no $.
|
||||
const shares_text = nextNonEmpty(staged.items, &cur) orelse break;
|
||||
const shares = parseSharesAmount(shares_text) orelse continue;
|
||||
|
||||
// Avg cost: line starts with `@ $`.
|
||||
const cost_text = nextNonEmpty(staged.items, &cur) orelse break;
|
||||
if (!std.mem.startsWith(u8, cost_text, "@ ")) continue;
|
||||
const avg_cost = parseDollarAmount(cost_text[2..]) orelse continue;
|
||||
|
||||
// Last price (skip), day-change-$ (skip), day-change-%
|
||||
// (skip): three lines of price-detail we don't currently
|
||||
// need. We could surface them later if a caller wants
|
||||
// them, but for synthesis the avg cost + market value is
|
||||
// enough.
|
||||
_ = nextNonEmpty(staged.items, &cur) orelse break; // last price
|
||||
_ = nextNonEmpty(staged.items, &cur) orelse break; // day change $
|
||||
|
||||
// Market value: dollar amount, no parens.
|
||||
const mv_text = nextNonEmpty(staged.items, &cur) orelse break;
|
||||
const market_value = parseDollarAmount(mv_text) orelse continue;
|
||||
|
||||
// The remaining lines (day-change-$/%, unreal G/L $/%,
|
||||
// est annual income) are the rest of this record but
|
||||
// we don't need them for synthesis. The next record's
|
||||
// popup anchor is what we'll find on the outer loop's
|
||||
// next iteration.
|
||||
i = cur; // resume scan past the consumed market-value line
|
||||
|
||||
try positions.append(allocator, .{
|
||||
.account_number = "",
|
||||
.account_name = "",
|
||||
.symbol = symbol,
|
||||
.description = description,
|
||||
.quantity = shares,
|
||||
.current_value = market_value,
|
||||
.cost_basis = shares * avg_cost,
|
||||
.is_cash = portfolio_mod.isMoneyMarketSymbol(symbol),
|
||||
});
|
||||
}
|
||||
|
||||
return positions.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// True when `line` is a record-start anchor like `GSLC , popup`.
|
||||
/// The trailing `, popup` is the stable signal — WF's hover
|
||||
/// affordance. Whitespace between the symbol and the comma is
|
||||
/// tolerated (WF emits exactly one space, but trim defends
|
||||
/// against future-proofing).
|
||||
fn isPopupAnchor(line: []const u8) bool {
|
||||
return std.mem.endsWith(u8, line, ", popup");
|
||||
}
|
||||
|
||||
/// Extract the symbol from a popup anchor line. Returns null if
|
||||
/// the line is the right shape but the symbol part is empty.
|
||||
fn popupSymbol(line: []const u8) ?[]const u8 {
|
||||
if (!isPopupAnchor(line)) return null;
|
||||
const before_comma = line[0 .. line.len - ", popup".len];
|
||||
const symbol = std.mem.trim(u8, before_comma, &.{ ' ', '\t' });
|
||||
if (symbol.len == 0) return null;
|
||||
return symbol;
|
||||
}
|
||||
|
||||
/// True when `line` looks like a footer-totals sentinel. WF's
|
||||
/// paste sometimes ends with one or two `ETFs Total` blocks; we
|
||||
/// also generously accept any line ending with " Total" so
|
||||
/// "Stocks Total", "Bonds Total", etc. don't slip through if a
|
||||
/// future paste includes them.
|
||||
fn isTotalLine(line: []const u8) bool {
|
||||
if (std.mem.eql(u8, line, "Total")) return true;
|
||||
return std.mem.endsWith(u8, line, " Total");
|
||||
}
|
||||
|
||||
/// Advance `cur_idx` past blank lines in `lines`, then return
|
||||
/// (and consume) the first non-blank line. Returns null if no
|
||||
/// non-blank line remains.
|
||||
fn nextNonEmpty(lines: []const []const u8, cur_idx: *usize) ?[]const u8 {
|
||||
while (cur_idx.* < lines.len) {
|
||||
const line = lines[cur_idx.*];
|
||||
cur_idx.* += 1;
|
||||
if (line.len == 0) continue;
|
||||
if (isTotalLine(line)) return null;
|
||||
return line;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parse a shares value like "906" or "1,020" — integers with
|
||||
/// optional thousands commas, no $ prefix. Returns null on any
|
||||
/// other shape (which lets the parent loop skip the record
|
||||
/// without aborting the whole paste).
|
||||
fn parseSharesAmount(raw: []const u8) ?f64 {
|
||||
// Reuse parseDollarAmount: it strips $/+/-/comma and parses
|
||||
// the rest as a float. WF shares lines have no $ but the
|
||||
// function is happy without one.
|
||||
return parseDollarAmount(raw);
|
||||
}
|
||||
|
||||
// ── Account resolution ───────────────────────────────────────
|
||||
//
|
||||
// Wells Fargo pastes carry no in-band account identifier (no
|
||||
// header, no per-row column, no embedded account number — see
|
||||
// the module doc-block). So after parsing we have to resolve the
|
||||
// account name from outside the paste: `accounts.srf` plus any
|
||||
// hints from the file path or an explicit `--account` override.
|
||||
//
|
||||
// This block is the WF-specific piece of import. Fidelity and
|
||||
// Schwab don't need it because their exports stamp the account
|
||||
// number per-row (Fidelity) or in the title line (Schwab).
|
||||
|
||||
/// Resolution result: borrowed slices into the matching
|
||||
/// `accounts.srf` entry. Both fields live as long as the
|
||||
/// `AccountMap` does.
|
||||
pub const Resolved = struct {
|
||||
account_number: []const u8,
|
||||
account_name: []const u8,
|
||||
};
|
||||
|
||||
/// Determine which `accounts.srf` entry a Wells Fargo paste
|
||||
/// belongs to. Resolution order:
|
||||
///
|
||||
/// 1. **Explicit `--account NAME`.** Match against
|
||||
/// `account::` exactly. If the override doesn't match any
|
||||
/// `institution::wells_fargo` entry, error out.
|
||||
/// 2. **Filename inference.** Take the basename of the source
|
||||
/// 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".
|
||||
/// 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
|
||||
/// (the lookup just falls through to the error).
|
||||
///
|
||||
/// On no-match, prints a stderr listing of the available WF
|
||||
/// entries so the user can pick one with `--account`.
|
||||
///
|
||||
/// Errors:
|
||||
/// - `error.UnknownAccount`: `--account NAME` didn't match a WF
|
||||
/// entry, or a matching entry has no `account_number::` field
|
||||
/// (which the downstream lookup keys on).
|
||||
/// - `error.AmbiguousWellsFargoAccount`: zero or 2+ WF entries in
|
||||
/// accounts.srf and no other signal to disambiguate.
|
||||
pub fn resolveAccount(
|
||||
io: std.Io,
|
||||
account_map: analysis.AccountMap,
|
||||
source_path: []const u8,
|
||||
explicit: ?[]const u8,
|
||||
) !Resolved {
|
||||
// 1. Explicit override.
|
||||
if (explicit) |name| {
|
||||
for (account_map.entries) |e| {
|
||||
const inst = e.institution orelse continue;
|
||||
if (!std.mem.eql(u8, inst, institution)) continue;
|
||||
if (std.mem.eql(u8, e.account, name)) {
|
||||
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();
|
||||
return error.UnknownAccount;
|
||||
}
|
||||
|
||||
// 2. Filename inference. Take everything after the last
|
||||
// `/` and before the extension. Stdin's `-` returns no
|
||||
// useful base; the inference simply fails and we fall
|
||||
// through to step 3.
|
||||
const inferred: ?analysis.AccountTaxEntry = blk: {
|
||||
if (std.mem.eql(u8, source_path, "-")) break :blk null;
|
||||
const base_with_ext = std.fs.path.basename(source_path);
|
||||
const dot_idx = std.mem.lastIndexOfScalar(u8, base_with_ext, '.');
|
||||
const base = if (dot_idx) |i| base_with_ext[0..i] else base_with_ext;
|
||||
|
||||
var match: ?analysis.AccountTaxEntry = null;
|
||||
for (account_map.entries) |e| {
|
||||
const inst = e.institution orelse continue;
|
||||
if (!std.mem.eql(u8, inst, institution)) continue;
|
||||
if (filenameMatchesAccount(base, e.account)) {
|
||||
if (match != null) {
|
||||
// More than one WF entry matched the
|
||||
// filename — punt to the user.
|
||||
break :blk null;
|
||||
}
|
||||
match = e;
|
||||
}
|
||||
}
|
||||
break :blk match;
|
||||
};
|
||||
if (inferred) |e| return resolutionFor(io, e);
|
||||
|
||||
// 3. Single-WF-entry fallback.
|
||||
var single: ?analysis.AccountTaxEntry = null;
|
||||
var wf_count: usize = 0;
|
||||
for (account_map.entries) |e| {
|
||||
const inst = e.institution orelse continue;
|
||||
if (!std.mem.eql(u8, inst, institution)) continue;
|
||||
wf_count += 1;
|
||||
single = e;
|
||||
}
|
||||
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);
|
||||
}
|
||||
try sw.interface.flush();
|
||||
return error.AmbiguousWellsFargoAccount;
|
||||
}
|
||||
|
||||
/// Convenience: resolve the account once and then patch every
|
||||
/// position's `account_number` / `account_name` fields with the
|
||||
/// resolved values. Used by `import` after `parsePaste`. The
|
||||
/// patched slices borrow from `account_map`, which the caller
|
||||
/// must keep alive for the lifetime of the positions.
|
||||
pub fn applyAccountToPositions(
|
||||
io: std.Io,
|
||||
account_map: analysis.AccountMap,
|
||||
source_path: []const u8,
|
||||
explicit: ?[]const u8,
|
||||
positions: []BrokeragePosition,
|
||||
) !void {
|
||||
const resolved = try resolveAccount(io, account_map, source_path, explicit);
|
||||
var idx: usize = 0;
|
||||
while (idx < positions.len) : (idx += 1) {
|
||||
positions[idx].account_number = resolved.account_number;
|
||||
positions[idx].account_name = resolved.account_name;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `Resolved` from an `accounts.srf` entry. Errors when
|
||||
/// the entry has no `account_number::` field, because the
|
||||
/// 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();
|
||||
return error.UnknownAccount;
|
||||
};
|
||||
return .{ .account_number = num, .account_name = entry.account };
|
||||
}
|
||||
|
||||
/// True when the source file's basename (without extension)
|
||||
/// looks like it refers to `account_name`. Implemented as a
|
||||
/// case-insensitive substring overlap on the trailing-digits
|
||||
/// tail of the account name (after `*` or end-of-string), with
|
||||
/// 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
|
||||
fn filenameMatchesAccount(filename: []const u8, account_name: []const u8) bool {
|
||||
// Extract the trailing digit run from the account name.
|
||||
// "Elizabeth IRA *3522" → "3522".
|
||||
var digits_start: usize = account_name.len;
|
||||
while (digits_start > 0) {
|
||||
const c = account_name[digits_start - 1];
|
||||
if (c < '0' or c > '9') break;
|
||||
digits_start -= 1;
|
||||
}
|
||||
const digits = account_name[digits_start..];
|
||||
|
||||
// If the account name ends in digits, the filename must
|
||||
// contain that exact digit run somewhere. This is the
|
||||
// strongest signal — WF account suffixes are unique within
|
||||
// a household.
|
||||
if (digits.len > 0 and std.mem.indexOf(u8, filename, digits) != null) return true;
|
||||
|
||||
// No digit suffix to compare; fall back to a fuzzy
|
||||
// letters-only overlap. Lowercase both sides; compare
|
||||
// alphanumeric runs only. If every alphanumeric run of the
|
||||
// account name appears in the filename in order, it's a
|
||||
// match.
|
||||
return alphaRunsContained(filename, account_name);
|
||||
}
|
||||
|
||||
/// True when every maximal alphanumeric run in `account_name`
|
||||
/// appears (case-insensitive, in order) somewhere inside
|
||||
/// `filename`. Used as a fallback in `filenameMatchesAccount`
|
||||
/// when the account has no digit suffix to anchor on.
|
||||
fn alphaRunsContained(filename: []const u8, account_name: []const u8) bool {
|
||||
var f_lower_buf: [256]u8 = undefined;
|
||||
if (filename.len > f_lower_buf.len) return false;
|
||||
for (filename, 0..) |c, i| f_lower_buf[i] = std.ascii.toLower(c);
|
||||
const f_lower = f_lower_buf[0..filename.len];
|
||||
|
||||
var i: usize = 0;
|
||||
var search_from: usize = 0;
|
||||
while (i < account_name.len) {
|
||||
// Skip non-alphanum.
|
||||
while (i < account_name.len and !std.ascii.isAlphanumeric(account_name[i])) : (i += 1) {}
|
||||
const start = i;
|
||||
while (i < account_name.len and std.ascii.isAlphanumeric(account_name[i])) : (i += 1) {}
|
||||
if (start == i) break;
|
||||
const acct_run = account_name[start..i];
|
||||
if (acct_run.len == 0) continue;
|
||||
|
||||
// Lowercase the run and find it in f_lower starting at
|
||||
// search_from.
|
||||
var run_lower_buf: [128]u8 = undefined;
|
||||
if (acct_run.len > run_lower_buf.len) return false;
|
||||
for (acct_run, 0..) |c, k| run_lower_buf[k] = std.ascii.toLower(c);
|
||||
const run_lower = run_lower_buf[0..acct_run.len];
|
||||
|
||||
const found = std.mem.indexOfPos(u8, f_lower, search_from, run_lower) orelse return false;
|
||||
search_from = found + acct_run.len;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Helper: print every `institution::wells_fargo` entry from
|
||||
/// the account map onto the given writer, one per line, indented.
|
||||
fn printEntries(w: *std.Io.Writer, account_map: analysis.AccountMap) !void {
|
||||
for (account_map.entries) |e| {
|
||||
const inst = e.institution orelse continue;
|
||||
if (!std.mem.eql(u8, inst, institution)) continue;
|
||||
try w.print(" - {s}\n", .{e.account});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "isPopupAnchor: recognizes WF record anchors" {
|
||||
try testing.expect(isPopupAnchor("GSLC , popup"));
|
||||
try testing.expect(isPopupAnchor("VTV , popup"));
|
||||
try testing.expect(!isPopupAnchor("GOLDMAN ACTIVEBETA ETF"));
|
||||
try testing.expect(!isPopupAnchor("ETFs Total"));
|
||||
try testing.expect(!isPopupAnchor(""));
|
||||
}
|
||||
|
||||
test "popupSymbol: extracts symbol token before ', popup'" {
|
||||
try testing.expectEqualStrings("GSLC", popupSymbol("GSLC , popup").?);
|
||||
try testing.expectEqualStrings("VO", popupSymbol("VO , popup").?);
|
||||
// Empty symbol part → null.
|
||||
try testing.expect(popupSymbol(", popup") == null);
|
||||
// Wrong shape → null.
|
||||
try testing.expect(popupSymbol("GSLC popup") == null);
|
||||
}
|
||||
|
||||
test "isTotalLine: matches WF footer sentinels" {
|
||||
try testing.expect(isTotalLine("ETFs Total"));
|
||||
try testing.expect(isTotalLine("Stocks Total"));
|
||||
try testing.expect(isTotalLine("Total"));
|
||||
try testing.expect(!isTotalLine("Subtotal"));
|
||||
try testing.expect(!isTotalLine("GSLC , popup"));
|
||||
}
|
||||
|
||||
test "parseSharesAmount: accepts integers with thousands commas" {
|
||||
try testing.expectApproxEqAbs(@as(f64, 906), parseSharesAmount("906").?, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 1020), parseSharesAmount("1,020").?, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 2597), parseSharesAmount("2,597").?, 0.01);
|
||||
}
|
||||
|
||||
test "parsePaste: header preamble plus three records" {
|
||||
const allocator = testing.allocator;
|
||||
// Mirrors the wf.txt structure — header preamble, then a
|
||||
// few records, then the totals footer. Tabs and blank
|
||||
// lines are intentional; the trim+nextNonEmpty pipeline
|
||||
// should handle them.
|
||||
const data =
|
||||
"Symbol/Description,click to sort \tTrade Date,click to sort \tShares\n" ++
|
||||
"@ Cost\n" ++
|
||||
",click to sort \tLast Price/\n" ++
|
||||
"Change\n" ++
|
||||
",click to sort \tMarket Value/\n" ++
|
||||
"Today's Change\n" ++
|
||||
"\tUnreal.\n" ++
|
||||
"Gain/Loss\n" ++
|
||||
",click to sort \tEstimated\n" ++
|
||||
"Annual Income\n" ++
|
||||
",click to sort\n" ++
|
||||
"\t\n" ++
|
||||
"GSLC , popup\n" ++
|
||||
"GOLDMAN ACTIVEBETA ETF\n" ++
|
||||
"\tMultiple(3) \t\n" ++
|
||||
"906\n" ++
|
||||
"@ $129.97\n" ++
|
||||
"\t\n" ++
|
||||
"$140.90\n" ++
|
||||
"+$0.31\n" ++
|
||||
"\t\n" ++
|
||||
"$127,655.40\n" ++
|
||||
"+$280.86 (+0.22%)\n" ++
|
||||
"\t\n" ++
|
||||
"+$9,906.42\n" ++
|
||||
"+8.41%\n" ++
|
||||
"\t\n" ++
|
||||
"$1,203.17\n" ++
|
||||
"\t\n" ++
|
||||
"VO , popup\n" ++
|
||||
"VANGUARD MID CAP ETF\n" ++
|
||||
"\tMultiple(2) \t\n" ++
|
||||
"1,020\n" ++
|
||||
"@ $74.30\n" ++
|
||||
"\t\n" ++
|
||||
"$77.41\n" ++
|
||||
"+$0.35\n" ++
|
||||
"\t\n" ++
|
||||
"$78,958.20\n" ++
|
||||
"+$357.00 (+0.45%)\n" ++
|
||||
"\t\n" ++
|
||||
"+$3,174.66\n" ++
|
||||
"+4.19%\n" ++
|
||||
"\t\n" ++
|
||||
"$1,104.66\n" ++
|
||||
"\t\n" ++
|
||||
"EEM , popup\n" ++
|
||||
"ISHARES MSCI EMRG MK ETF\n" ++
|
||||
"\t\n" ++
|
||||
"02/24/2026\n" ++
|
||||
"\t\n" ++
|
||||
"875\n" ++
|
||||
"@ $62.71\n" ++
|
||||
"\t\n" ++
|
||||
"$66.03\n" ++
|
||||
"+$0.57\n" ++
|
||||
"\t\n" ++
|
||||
"$57,776.25\n" ++
|
||||
"+$498.75 (+0.87%)\n" ++
|
||||
"\t\n" ++
|
||||
"+$2,906.67\n" ++
|
||||
"+5.30%\n" ++
|
||||
"\t\n" ++
|
||||
"$1,063.12\n" ++
|
||||
"\t\n" ++
|
||||
"ETFs Total\n" ++
|
||||
"\t\t\t\t\n" ++
|
||||
"$264,389.85\n";
|
||||
|
||||
const positions = try parsePaste(allocator, data);
|
||||
defer allocator.free(positions);
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), positions.len);
|
||||
|
||||
// GSLC: 906 shares × $129.97 avg = $117,752.82 cost basis;
|
||||
// market value $127,655.40.
|
||||
try testing.expectEqualStrings("GSLC", positions[0].symbol);
|
||||
try testing.expectEqualStrings("GOLDMAN ACTIVEBETA ETF", positions[0].description);
|
||||
try testing.expectApproxEqAbs(@as(f64, 906), positions[0].quantity.?, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 117_752.82), positions[0].cost_basis.?, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 127_655.40), positions[0].current_value.?, 0.01);
|
||||
try testing.expect(!positions[0].is_cash);
|
||||
|
||||
// VO: 1,020 × $74.30 = $75,786 cost; market $78,958.20.
|
||||
try testing.expectEqualStrings("VO", positions[1].symbol);
|
||||
try testing.expectApproxEqAbs(@as(f64, 1020), positions[1].quantity.?, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 75_786.00), positions[1].cost_basis.?, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 78_958.20), positions[1].current_value.?, 0.01);
|
||||
|
||||
// EEM: single-date format (`02/24/2026` instead of `Multiple(N)`),
|
||||
// so the parser handles both shapes by treating the trade-date
|
||||
// column as a generic skip.
|
||||
try testing.expectEqualStrings("EEM", positions[2].symbol);
|
||||
try testing.expectApproxEqAbs(@as(f64, 875), positions[2].quantity.?, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 875.0 * 62.71), positions[2].cost_basis.?, 0.01);
|
||||
try testing.expectApproxEqAbs(@as(f64, 57_776.25), positions[2].current_value.?, 0.01);
|
||||
}
|
||||
|
||||
test "parsePaste: no header preamble, no footer totals" {
|
||||
// Mirrors wf2.txt — same record format, no preamble at
|
||||
// top, no totals at bottom. Parser must reach EOF cleanly.
|
||||
const allocator = testing.allocator;
|
||||
const data =
|
||||
"\n" ++
|
||||
"GSLC , popup\n" ++
|
||||
"GOLDMAN ACTIVEBETA ETF\n" ++
|
||||
"\tMultiple(3) \t\n" ++
|
||||
"906\n" ++
|
||||
"@ $129.97\n" ++
|
||||
"\t\n" ++
|
||||
"$140.90\n" ++
|
||||
"+$0.31\n" ++
|
||||
"\t\n" ++
|
||||
"$127,655.40\n" ++
|
||||
"+$280.86 (+0.22%)\n" ++
|
||||
"\t\n" ++
|
||||
"+$9,906.42\n" ++
|
||||
"+8.41%\n" ++
|
||||
"\t\n" ++
|
||||
"$1,203.17\n";
|
||||
|
||||
const positions = try parsePaste(allocator, data);
|
||||
defer allocator.free(positions);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), positions.len);
|
||||
try testing.expectEqualStrings("GSLC", positions[0].symbol);
|
||||
try testing.expectApproxEqAbs(@as(f64, 906), positions[0].quantity.?, 0.01);
|
||||
}
|
||||
|
||||
test "parsePaste: empty input yields zero positions" {
|
||||
const allocator = testing.allocator;
|
||||
const positions = try parsePaste(allocator, "");
|
||||
defer allocator.free(positions);
|
||||
try testing.expectEqual(@as(usize, 0), positions.len);
|
||||
}
|
||||
|
||||
test "parsePaste: input with only header preamble (no records) yields zero" {
|
||||
const allocator = testing.allocator;
|
||||
const data =
|
||||
"Symbol/Description,click to sort \tTrade Date,click to sort \tShares\n" ++
|
||||
"@ Cost\n" ++
|
||||
",click to sort \tLast Price/\n";
|
||||
const positions = try parsePaste(allocator, data);
|
||||
defer allocator.free(positions);
|
||||
try testing.expectEqual(@as(usize, 0), positions.len);
|
||||
}
|
||||
|
||||
test "parsePaste: stops at ETFs Total footer (doesn't parse past it)" {
|
||||
const allocator = testing.allocator;
|
||||
// Two records, then a footer, then more positions that
|
||||
// SHOULD NOT be parsed (they belong to a phantom second
|
||||
// table that the user shouldn't have included). The
|
||||
// totals-line stop ensures we don't accidentally double-
|
||||
// count.
|
||||
const data =
|
||||
"GSLC , popup\n" ++
|
||||
"GOLDMAN ACTIVEBETA ETF\n" ++
|
||||
"\tMultiple(3) \t\n" ++
|
||||
"906\n" ++
|
||||
"@ $129.97\n" ++
|
||||
"\t\n" ++
|
||||
"$140.90\n" ++
|
||||
"+$0.31\n" ++
|
||||
"\t\n" ++
|
||||
"$127,655.40\n" ++
|
||||
"+$280.86 (+0.22%)\n" ++
|
||||
"\t\n" ++
|
||||
"+$9,906.42\n" ++
|
||||
"+8.41%\n" ++
|
||||
"\t\n" ++
|
||||
"$1,203.17\n" ++
|
||||
"\t\n" ++
|
||||
"ETFs Total\n" ++
|
||||
"$127,655.40\n" ++
|
||||
"GHOST , popup\n" ++ // should NOT be parsed
|
||||
"SHOULD NOT APPEAR\n" ++
|
||||
"\tMultiple(1) \t\n" ++
|
||||
"1\n" ++
|
||||
"@ $1.00\n" ++
|
||||
"\t\n" ++
|
||||
"$1.00\n" ++
|
||||
"+$0.00\n" ++
|
||||
"\t\n" ++
|
||||
"$1.00\n";
|
||||
|
||||
const positions = try parsePaste(allocator, data);
|
||||
defer allocator.free(positions);
|
||||
try testing.expectEqual(@as(usize, 1), positions.len);
|
||||
try testing.expectEqualStrings("GSLC", positions[0].symbol);
|
||||
}
|
||||
|
||||
test "parsePaste: money-market symbol gets is_cash=true" {
|
||||
const allocator = testing.allocator;
|
||||
// WMPXX is the Allspring (née Wells Fargo) money-market
|
||||
// fund; it's in the canonical money-market list, so even
|
||||
// without a `**` suffix or unit-price hint, the parser
|
||||
// tags it as cash. Using a WF-house ticker here keeps the
|
||||
// fixture credible — SWVXX would never show up on a Wells
|
||||
// Fargo holdings page.
|
||||
const data =
|
||||
"WMPXX , popup\n" ++
|
||||
"ALLSPRING MONEY MARKET FUND\n" ++
|
||||
"\tMultiple(1) \t\n" ++
|
||||
"5000\n" ++
|
||||
"@ $1.00\n" ++
|
||||
"\t\n" ++
|
||||
"$1.00\n" ++
|
||||
"$0.00\n" ++
|
||||
"\t\n" ++
|
||||
"$5,000.00\n" ++
|
||||
"+$0.00 (0.00%)\n" ++
|
||||
"\t\n" ++
|
||||
"+$0.00\n" ++
|
||||
"0.00%\n" ++
|
||||
"\t\n" ++
|
||||
"$200.00\n";
|
||||
|
||||
const positions = try parsePaste(allocator, data);
|
||||
defer allocator.free(positions);
|
||||
try testing.expectEqual(@as(usize, 1), positions.len);
|
||||
try testing.expect(positions[0].is_cash);
|
||||
}
|
||||
|
||||
// ── Resolver tests ───────────────────────────────────────────
|
||||
|
||||
/// Test helper: build an `AccountMap` from compile-time entries.
|
||||
/// Mirrors the helper in `commands/import.zig`'s test block;
|
||||
/// duplicated here so resolver tests don't depend on import's
|
||||
/// test-only infrastructure.
|
||||
fn testAccountMap(allocator: std.mem.Allocator, entries: []const analysis.AccountTaxEntry) !analysis.AccountMap {
|
||||
var owned = try allocator.alloc(analysis.AccountTaxEntry, entries.len);
|
||||
for (entries, 0..) |e, i| {
|
||||
owned[i] = .{
|
||||
.account = try allocator.dupe(u8, e.account),
|
||||
.tax_type = e.tax_type,
|
||||
.institution = if (e.institution) |s| try allocator.dupe(u8, s) else null,
|
||||
.account_number = if (e.account_number) |s| try allocator.dupe(u8, s) else null,
|
||||
};
|
||||
}
|
||||
return .{ .entries = owned, .allocator = allocator };
|
||||
}
|
||||
|
||||
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"));
|
||||
// Different digit suffix → no match.
|
||||
try testing.expect(!filenameMatchesAccount("Elizabeth_IRA_7891", "Elizabeth IRA *3522"));
|
||||
try testing.expect(!filenameMatchesAccount("portfolio_mom", "Elizabeth IRA *3522"));
|
||||
}
|
||||
|
||||
test "filenameMatchesAccount: alpha-only fallback when account has no digit suffix" {
|
||||
// No trailing digits to anchor on — falls through to the
|
||||
// alpha-runs-contained check.
|
||||
try testing.expect(filenameMatchesAccount("emils_brokerage", "Emils Brokerage"));
|
||||
// Out-of-order tokens don't match: alphaRunsContained
|
||||
// requires every account-name run to appear in order in
|
||||
// the filename.
|
||||
try testing.expect(!filenameMatchesAccount("Brokerage_Emils", "Emils Brokerage"));
|
||||
// Partial overlap also doesn't match — every run must be
|
||||
// present.
|
||||
try testing.expect(!filenameMatchesAccount("emils_only", "Emils Brokerage"));
|
||||
}
|
||||
|
||||
test "filenameMatchesAccount: case-insensitive fallback" {
|
||||
try testing.expect(filenameMatchesAccount("EMILS_brokerage", "Emils Brokerage"));
|
||||
try testing.expect(filenameMatchesAccount("emils_BROKERAGE", "Emils Brokerage"));
|
||||
}
|
||||
|
||||
test "alphaRunsContained: every alphanumeric run from account appears in order" {
|
||||
try testing.expect(alphaRunsContained("emils_brokerage", "Emils Brokerage"));
|
||||
try testing.expect(alphaRunsContained("--emils-brokerage--", "Emils Brokerage"));
|
||||
try testing.expect(!alphaRunsContained("brokerage_emils", "Emils Brokerage")); // order matters
|
||||
try testing.expect(!alphaRunsContained("emils_only", "Emils Brokerage")); // missing run
|
||||
// Empty account name has no runs → trivially true.
|
||||
try testing.expect(alphaRunsContained("anything", ""));
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
try testing.expectError(error.UnknownAccount, resolveAccount(testing.io, account_map, "anything.txt", "Wrong Account"));
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
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" },
|
||||
// Non-WF entry shouldn't interfere.
|
||||
.{ .account = "Mom 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);
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
try testing.expectError(error.AmbiguousWellsFargoAccount, resolveAccount(testing.io, account_map, "unrelated_filename.txt", null));
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
try testing.expectError(error.AmbiguousWellsFargoAccount, resolveAccount(testing.io, account_map, "anything.txt", null));
|
||||
}
|
||||
|
||||
test "resolveAccount: WF entry without account_number → UnknownAccount" {
|
||||
// Pins the requirement that WF entries in accounts.srf MUST
|
||||
// carry an `account_number::` field — the downstream
|
||||
// `findByInstitutionAccount` lookup keys on it. Without
|
||||
// this guard the import would silently produce
|
||||
// "unmapped account" errors at synthesizeLots time with
|
||||
// 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 },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
try testing.expectError(error.UnknownAccount, resolveAccount(testing.io, account_map, "Elizabeth_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" },
|
||||
});
|
||||
defer account_map.deinit();
|
||||
|
||||
var positions = [_]BrokeragePosition{
|
||||
.{ .account_number = "", .account_name = "", .symbol = "VTI", .description = "", .quantity = 10, .current_value = 1000, .cost_basis = 800, .is_cash = false },
|
||||
.{ .account_number = "", .account_name = "", .symbol = "AAPL", .description = "", .quantity = 5, .current_value = 1000, .cost_basis = 750, .is_cash = false },
|
||||
};
|
||||
|
||||
try applyAccountToPositions(testing.io, account_map, "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);
|
||||
}
|
||||
|
|
@ -91,22 +91,35 @@ const cache = @import("../cache/store.zig");
|
|||
const atomic = @import("../atomic.zig");
|
||||
const fidelity = @import("../brokerage/fidelity.zig");
|
||||
const schwab = @import("../brokerage/schwab.zig");
|
||||
const wells_fargo = @import("../brokerage/wells_fargo.zig");
|
||||
const brokerage_types = @import("../brokerage/types.zig");
|
||||
const analysis = @import("../analytics/analysis.zig");
|
||||
|
||||
const BrokeragePosition = brokerage_types.BrokeragePosition;
|
||||
|
||||
/// Source brokerage for the import. Each variant carries the path
|
||||
/// to the export file. We accept exactly one source per run; the
|
||||
/// parser rejects mixed flags.
|
||||
/// Source brokerage for the import. Fidelity and Schwab carry an
|
||||
/// account number in the export rows themselves; Wells Fargo's
|
||||
/// paste does not, so the WF variant carries an optional explicit
|
||||
/// account-name override (`--account NAME`) that the resolver
|
||||
/// uses when filename inference fails.
|
||||
pub const Source = union(enum) {
|
||||
fidelity: []const u8,
|
||||
schwab: []const u8,
|
||||
wells_fargo: WellsFargoArgs,
|
||||
|
||||
pub const WellsFargoArgs = struct {
|
||||
path: []const u8,
|
||||
/// `--account NAME` value, if the user supplied one.
|
||||
/// Otherwise the resolver falls back to filename
|
||||
/// inference and finally to a single-WF-entry lookup.
|
||||
account_override: ?[]const u8,
|
||||
};
|
||||
|
||||
pub fn label(self: Source) []const u8 {
|
||||
return switch (self) {
|
||||
.fidelity => "fidelity",
|
||||
.schwab => "schwab",
|
||||
.wells_fargo => "wells_fargo",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +127,7 @@ pub const Source = union(enum) {
|
|||
return switch (self) {
|
||||
.fidelity => |p| p,
|
||||
.schwab => |p| p,
|
||||
.wells_fargo => |a| a.path,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -129,7 +143,7 @@ pub const meta: framework.Meta = .{
|
|||
.group = .hygiene,
|
||||
.synopsis = "Synthesize a portfolio file from a brokerage holdings export",
|
||||
.help =
|
||||
\\Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE) [-y]
|
||||
\\Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE | --wells-fargo FILE [--account NAME]) [-y]
|
||||
\\
|
||||
\\Synthesize a portfolio file from a brokerage positions export.
|
||||
\\Each run REPLACES the target portfolio file with synthetic lots
|
||||
|
|
@ -145,15 +159,26 @@ pub const meta: framework.Meta = .{
|
|||
\\inert rather than misleading.
|
||||
\\
|
||||
\\Required:
|
||||
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
|
||||
\\ concrete path, not a glob). REQUIRED.
|
||||
\\ --fidelity <CSV> Fidelity positions CSV
|
||||
\\ ("All accounts" → Positions tab → Download)
|
||||
\\ --schwab <CSV> Schwab per-account positions CSV
|
||||
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
|
||||
\\ concrete path, not a glob). REQUIRED.
|
||||
\\ --fidelity <CSV> Fidelity positions CSV
|
||||
\\ ("All accounts" → Positions tab → Download)
|
||||
\\ --schwab <CSV> Schwab per-account positions CSV
|
||||
\\ --wells-fargo <FILE> Wells Fargo paste (copy the rendered
|
||||
\\ positions table from the WF portal
|
||||
\\ and save to a file). Pass `-` to
|
||||
\\ read from stdin.
|
||||
\\
|
||||
\\Options:
|
||||
\\ -y, --yes Don't prompt before overwriting an
|
||||
\\ existing file.
|
||||
\\ --account <NAME> (Wells Fargo only) explicit account
|
||||
\\ name to attribute the lots to. Must
|
||||
\\ match an entry in accounts.srf. If
|
||||
\\ omitted, the importer infers the
|
||||
\\ account from the filename, then
|
||||
\\ falls back to the single WF entry
|
||||
\\ in accounts.srf.
|
||||
\\ -y, --yes Don't prompt before overwriting an
|
||||
\\ existing file.
|
||||
\\
|
||||
\\Account-name resolution requires `accounts.srf` next to the
|
||||
\\target file with `institution::` + `account_number::` entries
|
||||
|
|
@ -173,6 +198,8 @@ pub const meta: framework.Meta = .{
|
|||
CannotReadCsv,
|
||||
CannotReadAccountsFile,
|
||||
UnmappedAccount,
|
||||
AmbiguousWellsFargoAccount,
|
||||
UnknownAccount,
|
||||
UserDeclined,
|
||||
WriteFailed,
|
||||
},
|
||||
|
|
@ -181,6 +208,8 @@ pub const meta: framework.Meta = .{
|
|||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
var fidelity_path: ?[]const u8 = null;
|
||||
var schwab_path: ?[]const u8 = null;
|
||||
var wells_fargo_path: ?[]const u8 = null;
|
||||
var account_override: ?[]const u8 = null;
|
||||
var yes = false;
|
||||
|
||||
var i: usize = 0;
|
||||
|
|
@ -200,6 +229,20 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
}
|
||||
i += 1;
|
||||
schwab_path = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "--wells-fargo")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
wells_fargo_path = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "--account")) {
|
||||
if (i + 1 >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
i += 1;
|
||||
account_override = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
|
||||
yes = true;
|
||||
} else {
|
||||
|
|
@ -210,17 +253,33 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
}
|
||||
}
|
||||
|
||||
if (fidelity_path != null and schwab_path != null) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --fidelity and --schwab are mutually exclusive (one source per import)\n");
|
||||
// Mutual-exclusion check across the three source flags. Two
|
||||
// of three set still counts as a conflict.
|
||||
var source_count: u8 = 0;
|
||||
if (fidelity_path != null) source_count += 1;
|
||||
if (schwab_path != null) source_count += 1;
|
||||
if (wells_fargo_path != null) source_count += 1;
|
||||
if (source_count > 1) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n");
|
||||
return error.ConflictingSources;
|
||||
}
|
||||
|
||||
// `--account` is a Wells-Fargo-only knob; the other sources
|
||||
// carry per-row account_numbers in the export. Reject up
|
||||
// front so the user notices early.
|
||||
if (account_override != null and wells_fargo_path == null) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n");
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
|
||||
const source: Source = if (fidelity_path) |p|
|
||||
.{ .fidelity = p }
|
||||
else if (schwab_path) |p|
|
||||
.{ .schwab = p }
|
||||
else if (wells_fargo_path) |p|
|
||||
.{ .wells_fargo = .{ .path = p, .account_override = account_override } }
|
||||
else {
|
||||
try cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE or --schwab FILE)\n");
|
||||
try cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n");
|
||||
return error.MissingSource;
|
||||
};
|
||||
|
||||
|
|
@ -242,22 +301,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// multiple paths, they need to pick one explicitly.
|
||||
const target_path = try resolveSingleTarget(ctx);
|
||||
|
||||
// ── Read & parse the brokerage export ─────────────────────
|
||||
// ── Read the brokerage export ─────────────────────────────
|
||||
//
|
||||
// `-` selects stdin (for the paste-from-clipboard workflow);
|
||||
// any other path reads from disk. Same convention regardless
|
||||
// of source.
|
||||
const csv_path = parsed.source.path();
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
return error.CannotReadCsv;
|
||||
};
|
||||
const csv_data = try readSourceData(io, allocator, csv_path);
|
||||
defer allocator.free(csv_data);
|
||||
|
||||
const positions: []const BrokeragePosition = switch (parsed.source) {
|
||||
// ── Parse ─────────────────────────────────────────────────
|
||||
const positions: []BrokeragePosition = switch (parsed.source) {
|
||||
.fidelity => try fidelity.parseCsv(allocator, csv_data),
|
||||
.schwab => blk: {
|
||||
const r = try schwab.parseCsv(allocator, csv_data);
|
||||
break :blk r.positions;
|
||||
},
|
||||
.wells_fargo => try wells_fargo.parsePaste(allocator, csv_data),
|
||||
};
|
||||
defer allocator.free(positions);
|
||||
|
||||
|
|
@ -281,6 +341,21 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
};
|
||||
defer account_map.deinit();
|
||||
|
||||
// ── WF: resolve and patch the per-row account_number ──────
|
||||
//
|
||||
// Wells Fargo pastes don't carry an account identifier, so
|
||||
// every position came back with `account_number = ""`. Defer
|
||||
// to `wells_fargo.applyAccountToPositions` to resolve
|
||||
// (explicit `--account` → filename-inferred → single-WF-entry
|
||||
// fallback) and rewrite every position's
|
||||
// account_number/account_name accordingly. The downstream
|
||||
// `synthesizeLots` lookup then works uniformly across
|
||||
// brokerages.
|
||||
if (parsed.source == .wells_fargo) {
|
||||
const wf_args = parsed.source.wells_fargo;
|
||||
try wells_fargo.applyAccountToPositions(io, account_map, csv_path, wf_args.account_override, positions);
|
||||
}
|
||||
|
||||
// ── Synthesize lots ───────────────────────────────────────
|
||||
const lots = synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today) catch |err| switch (err) {
|
||||
error.UnmappedAccount => {
|
||||
|
|
@ -330,7 +405,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
for (lots) |lot| {
|
||||
if (lot.account) |a| {
|
||||
if (!seen.contains(a)) {
|
||||
seen.put(a, {}) catch {};
|
||||
try seen.put(a, {});
|
||||
account_count += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -383,6 +458,28 @@ fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
|
|||
return pat;
|
||||
}
|
||||
|
||||
/// Read the source bytes from `path`. The literal path `-` reads
|
||||
/// from stdin so users can paste directly into a heredoc-style
|
||||
/// invocation; any other value reads from disk. Returns owned
|
||||
/// bytes the caller must free.
|
||||
fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]const u8 {
|
||||
if (std.mem.eql(u8, path, "-")) {
|
||||
var stdin_buf: [4096]u8 = undefined;
|
||||
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf);
|
||||
const data = stdin_reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
try cli.stderrPrint(io, "Error: Cannot read source data from stdin\n");
|
||||
return error.CannotReadCsv;
|
||||
};
|
||||
return data;
|
||||
}
|
||||
return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read source file: {s}\n", .{path}) catch "Error: Cannot read source file\n";
|
||||
try cli.stderrPrint(io, msg);
|
||||
return error.CannotReadCsv;
|
||||
};
|
||||
}
|
||||
|
||||
/// Prompt for y/N confirmation on stderr, read one line from stdin.
|
||||
/// Returns true only on a 'y' or 'Y' answer (with optional whitespace);
|
||||
/// anything else (including EOF / empty) is "no".
|
||||
|
|
@ -443,7 +540,7 @@ fn synthesizeLots(
|
|||
for (positions) |pos| {
|
||||
if (account_map.findByInstitutionAccount(institution, pos.account_number) == null) {
|
||||
if (!seen.contains(pos.account_number)) {
|
||||
seen.put(pos.account_number, {}) catch {};
|
||||
try seen.put(pos.account_number, {});
|
||||
try unmapped.append(allocator, pos.account_number);
|
||||
}
|
||||
}
|
||||
|
|
@ -854,9 +951,11 @@ test "parseArgs: --fidelity without value errors" {
|
|||
test "Source.label: returns broker name" {
|
||||
try testing.expectEqualStrings("fidelity", (Source{ .fidelity = "" }).label());
|
||||
try testing.expectEqualStrings("schwab", (Source{ .schwab = "" }).label());
|
||||
try testing.expectEqualStrings("wells_fargo", (Source{ .wells_fargo = .{ .path = "", .account_override = null } }).label());
|
||||
}
|
||||
|
||||
test "Source.path: returns the CSV path" {
|
||||
try testing.expectEqualStrings("/x.csv", (Source{ .fidelity = "/x.csv" }).path());
|
||||
try testing.expectEqualStrings("/y.csv", (Source{ .schwab = "/y.csv" }).path());
|
||||
try testing.expectEqualStrings("/z.txt", (Source{ .wells_fargo = .{ .path = "/z.txt", .account_override = null } }).path());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,8 +85,12 @@ pub const money_market_symbols = [_][]const u8{
|
|||
"SPAXX",
|
||||
"SPRXX", "FDRXX", "FDLXX", "FZFXX", "FZDXX", "FTEXX",
|
||||
"FDIXX",
|
||||
// Wells Fargo / Allspring (Allspring is the WAM rebrand;
|
||||
// tickers retained their classic letters for legacy holders)
|
||||
"WMPXX", "WFFXX", "NWGXX", "GVIXX",
|
||||
// Federated, BlackRock, JPM common tickers
|
||||
"GOFXX", "TSCXX", "MJLXX",
|
||||
"GOFXX",
|
||||
"TSCXX", "MJLXX",
|
||||
};
|
||||
|
||||
/// Returns true when `symbol` appears in the well-known money-market
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue