From 809146b1119c4214bf4378859f8c69d3210c7fdf Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 21 May 2026 16:14:38 -0700 Subject: [PATCH] initial wells fargo parsing implementation --- src/analytics/analysis.zig | 24 + src/brokerage/wells_fargo.zig | 901 ++++++++++++++++++++++++++++++++++ src/commands/import.zig | 147 +++++- src/models/portfolio.zig | 6 +- 4 files changed, 1053 insertions(+), 25 deletions(-) create mode 100644 src/brokerage/wells_fargo.zig diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index a22350e..eaf3238 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -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 diff --git a/src/brokerage/wells_fargo.zig b/src/brokerage/wells_fargo.zig new file mode 100644 index 0000000..a18efc8 --- /dev/null +++ b/src/brokerage/wells_fargo.zig @@ -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 +//! ` , popup` line as the data anchor and ignores +//! everything before it. +//! +//! Each position record looks like: +//! +//! ``` +//! GSLC , popup ← record anchor: , 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 +//! +//! $140.90 ← last price +//! +$0.31 ← day change ($) +//! +//! $127,655.40 ← market value +//! +$280.86 (+0.22%) ← day change ($, %) +//! +//! +$9,906.42 ← unrealized gain/loss ($) +//! +8.41% ← unrealized gain/loss (%) +//! +//! $1,203.17 ← est. annual income +//! ← 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 ` , 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 ` , 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); +} diff --git a/src/commands/import.zig b/src/commands/import.zig index 5a0dcf0..2ff3293 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -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 import (--fidelity FILE | --schwab FILE) [-y] + \\Usage: zfin -p 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 Target portfolio file (must be a single - \\ concrete path, not a glob). REQUIRED. - \\ --fidelity Fidelity positions CSV - \\ ("All accounts" → Positions tab → Download) - \\ --schwab Schwab per-account positions CSV + \\ -p, --portfolio Target portfolio file (must be a single + \\ concrete path, not a glob). REQUIRED. + \\ --fidelity Fidelity positions CSV + \\ ("All accounts" → Positions tab → Download) + \\ --schwab Schwab per-account positions CSV + \\ --wells-fargo 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 (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()); } diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index cd9721c..18cb250 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -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