diff --git a/AGENTS.md b/AGENTS.md index 348e359..0c5b85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -994,3 +994,4 @@ command. | [SRF](https://git.lerch.org/lobo/srf) | Cache file format, portfolio/watchlist parsing, serialization | | [libvaxis](https://github.com/rockorager/libvaxis) (v0.6.0) | Terminal UI rendering | | [z2d](https://github.com/vancluever/z2d) (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) | +| [biff8](https://git.lerch.org/lobo/biff8) | Legacy `.xls` (BIFF8) reader for the Wells Fargo positions export | diff --git a/README.md b/README.md index ae2cef4..2bb7a14 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ src/ types.zig Normalized BrokeragePosition record + dollar parser fidelity.zig Fidelity "Download Positions" CSV parser schwab.zig Schwab export parser - wells_fargo.zig Wells Fargo export parser + wells_fargo.zig Wells Fargo positions spreadsheet (.xls) parser views/ portfolio_sections.zig Portfolio view model (renderer-agnostic StyleIntent) compare.zig Compare view model @@ -248,6 +248,7 @@ acknowledgments.srf Acknowledged review-tab findings (app-maintained) | [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.6.0) | Terminal UI rendering | | [z2d](https://github.com/vancluever/z2d) | Git (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) | | [zeit](https://github.com/rockorager/zeit) | Git (v0.9.0) | Calendar arithmetic + timezone conversion (ET) | +| [biff8](https://git.lerch.org/lobo/biff8) | Git | Wells Fargo `.xls` export reading | ## Building diff --git a/build.zig b/build.zig index 5f6fbb1..0cc9dd0 100644 --- a/build.zig +++ b/build.zig @@ -35,6 +35,13 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + // biff8: read-only legacy .xls (BIFF8) reader, used by the Wells + // Fargo positions-export parser in src/brokerage/wells_fargo.zig. + const biff8_dep = b.dependency("biff8", .{ + .target = target, + .optimize = optimize, + }); + const srf_mod = srf_dep.module("srf"); const shiller_mod = b.addModule("shiller_year", .{ @@ -58,6 +65,7 @@ pub fn build(b: *std.Build) void { .imports = &.{ .{ .name = "srf", .module = srf_mod }, .{ .name = "zeit", .module = zeit_dep.module("zeit") }, + .{ .name = "biff8", .module = biff8_dep.module("biff8") }, .{ .name = "build_info", .module = build_info }, }, }); @@ -71,6 +79,7 @@ pub fn build(b: *std.Build) void { .{ .name = "z2d", .module = z2d_dep.module("z2d") }, .{ .name = "zeit", .module = zeit_dep.module("zeit") }, .{ .name = "websocket", .module = websocket_dep.module("websocket") }, + .{ .name = "biff8", .module = biff8_dep.module("biff8") }, .{ .name = "build_info", .module = build_info }, .{ .name = "shiller_year", .module = shiller_mod }, .{ .name = "config_docs", .module = configDocsModule(b) }, @@ -140,6 +149,7 @@ pub fn build(b: *std.Build) void { .imports = &.{ .{ .name = "srf", .module = srf_mod }, .{ .name = "zeit", .module = zeit_dep.module("zeit") }, + .{ .name = "biff8", .module = biff8_dep.module("biff8") }, .{ .name = "build_info", .module = build_info }, }, }), diff --git a/build.zig.zon b/build.zig.zon index d56b198..733d721 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -24,6 +24,10 @@ .url = "git+https://github.com/karlseguin/websocket.zig#99df0d3533a41cbcd5ff59b9af68bbfe6169cc62", .hash = "websocket-0.1.0-ZPISdangBAAgWPap_MVU6Nk4rj3GT3xuJuF0IIv8HN6G", }, + .biff8 = .{ + .url = "git+https://git.lerch.org/lobo/biff8#0d1b71a021a9fbd2de2e034b926505c8ec18872d", + .hash = "biff8-0.0.0-Wpwf6n66AQBH5r944iEGTKIVm4Gd0YTcRDER4VQPFkGW", + }, }, .paths = .{ "build", diff --git a/docs/guides/audit-against-brokerage.md b/docs/guides/audit-against-brokerage.md index eb29560..5279493 100644 --- a/docs/guides/audit-against-brokerage.md +++ b/docs/guides/audit-against-brokerage.md @@ -20,14 +20,15 @@ from a supported broker. ## Supported brokers and how to export -`zfin audit` reconciles against **Fidelity** and **Schwab**. (Wells -Fargo is handled by [`import`](#what-about-wells-fargo), not audit.) +`zfin audit` reconciles against **Fidelity**, **Schwab**, and **Wells +Fargo**. -| Broker | How to export | Flag | -|--------------------------|-------------------------------------------------------------------------------------------------------------|--------------------| -| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity ` | -| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab ` | -| **Schwab** (summary) | *Accounts -> Summary*: select the accounts table and copy it ([what to copy](#schwab-summary-what-to-copy)) | `--schwab-summary` | +| Broker | How to export | Flag | +|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------| +| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity ` | +| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab ` | +| **Schwab** (summary) | *Accounts -> Summary*: select the accounts table and copy it ([what to copy](#schwab-summary-what-to-copy)) | `--schwab-summary` | +| **Wells Fargo** | Download Type **Portfolio-Expanded Detail**, Portfolio View **Positions**, all brokerage accounts (an `.xls`) | `--wells-fargo ` | The two Schwab inputs differ in detail: the **per-account CSV** has full per-position data (shares, price, value); the **summary paste** carries @@ -260,10 +261,15 @@ What it considers: `Portfolio_Positions_Jun-19.csv`; the recency window keeps zfin reconciling *the one you just pulled*, not last quarter's. -- **Detected by content, not filename.** zfin sniffs the first lines -- - Fidelity begins `Account Number`/`Account Name`, a Schwab CSV begins - `"Positions for ...`, a Schwab summary contains `Account number ending - in`. A renamed file still works; an unrelated CSV is skipped. +- **Detected by content, not filename.** zfin sniffs the contents -- + Fidelity by its legal footer, a Schwab CSV by its leading `"Positions + for ...`, a Schwab summary by `Account number ending in`, a Wells + Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still + works; an unrelated file is skipped. +- **A Wells Fargo export with none of your accounts is skipped.** One WF + download covers a whole household, so if you also manage someone + else's WF accounts, their export in your download folder is noted and + skipped rather than reported as eight unmapped accounts. So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to "download from your broker, run `zfin audit`, done." @@ -277,10 +283,11 @@ portfolio. It does that through - The export carries an account number -- Schwab's from the "Positions for account ...1234" title, Fidelity's from the Account Number column, - the summary's from "...ending in 1234". + the summary's from "...ending in 1234", Wells Fargo's from each row's + `*1234`. - zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`, - `schwab`) **and** `account_number::` match, and compares against that - account's lots. + `schwab`, `wells_fargo`) **and** `account_number::` match, and + compares against that account's lots. - **No match -> the account is shown as `unmapped`** and flagged as a discrepancy. Fix it by adding `institution::` and `account_number::` to that account in `accounts.srf` (a placeholder number you recognize @@ -344,12 +351,13 @@ treatment to track drift. See ## Why it's finicky -- The parsers are **broker-specific and hardcode each export's column - layout** -- if Fidelity or Schwab changes their format, parsing can - break (Fidelity's header is validated to catch this; Schwab's is not). - They are not full RFC-4180 CSV parsers (no escaped quotes or - multi-line fields) -- fine for the real exports, not for arbitrary - CSVs. +- The parsers are **broker-specific**. The CSV parsers hardcode each + export's column layout -- if Fidelity or Schwab changes their format, + parsing can break (both validate their header to catch this). They are + not full RFC-4180 CSV parsers (no escaped quotes or multi-line fields) + -- fine for the real exports, not for arbitrary CSVs. The Wells Fargo + parser finds columns by header name and checks every per-account total + against the lots under it, so a layout change fails loudly. - Matching is only as good as the `institution::` / `account_number::` entries you keep in `accounts.srf`. - Options, CDs, and cash are reconciled separately from share counts. @@ -358,13 +366,24 @@ None of this is a reason to skip it -- it's the single best way to keep your records honest -- just know it expects some setup and an occasional manual nudge. -### What about Wells Fargo? +### Wells Fargo: one file, every account, real lots -Wells Fargo's portal has no clean positions export, so it isn't an -`audit` target. Instead, [`zfin import --wells-fargo`](../reference/cli/import.md) -rebuilds a portfolio file from a paste of the WF positions table (copy -the rendered table from the brokerage portal and save it to a file). -Fidelity and Schwab exports can be imported the same way. +The Wells Fargo spreadsheet differs from the CSV exports in two ways +worth knowing: + +- **It covers the whole household.** Every brokerage account is in one + file, each row tagged with its account as `*1234`. +- **It lists tax lots, not positions.** Audit sums them per account and + symbol before comparing, so it doesn't matter whether your portfolio + holds the position as one lot or many. It also means + [`zfin import --wells-fargo`](../reference/cli/import.md) can build a + portfolio file with every lot's real trade date and cost, which is the + easy way to keep a WF-managed portfolio current. + +Pick **Portfolio-Expanded Detail**, not Collapsed: the collapsed download +has no lot rows, and zfin rejects it with a message saying so. Cash +(balance, sweep, and accrued interest) is compared per account against +your cash lots. > **Keep brokerage exports private.** They contain real account numbers > and holdings. Store them outside any git repository and delete them @@ -374,7 +393,7 @@ Fidelity and Schwab exports can be imported the same way. - [`zfin audit` reference](../reference/cli/audit.md) -- every flag. - [Map your accounts](set-up-accounts.md) -- the `institution` / `account_number` matching keys. -- [`zfin import`](../reference/cli/import.md) -- build a portfolio file *from* an export (including Wells Fargo). +- [`zfin import`](../reference/cli/import.md) -- build a portfolio file *from* an export. --- diff --git a/docs/reference/cli/audit.md b/docs/reference/cli/audit.md index e8fbc7a..df72130 100644 --- a/docs/reference/cli/audit.md +++ b/docs/reference/cli/audit.md @@ -16,17 +16,20 @@ discrepancies. ## Options -| Flag | Effect | -|--------------------|----------------------------------------------------------------------------| -| `--verbose` | Show full reconciliation output even when clean. | -| `--stale-days ` | Manual-price staleness threshold (default 3). | -| `--fidelity ` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). | -| `--schwab ` | Schwab per-account positions CSV. | -| `--schwab-summary` | Schwab account summary: paste from the summary page to stdin, then Ctrl-D. | +| Flag | Effect | +|-----------------------|---------------------------------------------------------------------------------------------| +| `--verbose` | Show full reconciliation output even when clean. | +| `--stale-days ` | Manual-price staleness threshold (default 3). | +| `--fidelity ` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). | +| `--schwab ` | Schwab per-account positions CSV. | +| `--schwab-summary` | Schwab account summary: paste from the summary page to stdin, then Ctrl-D. | +| `--wells-fargo ` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). | Reconciliation matches export accounts to yours via `institution::` and `account_number::` in [`accounts.srf`](../config/accounts-srf.md); an -unmatched account is reported as "unmapped." +unmatched account is reported as "unmapped." A discovered Wells Fargo +export none of whose accounts are mapped is skipped with a note instead, +since it is almost certainly another portfolio's. The hygiene check also flags newly-appeared lots worth at least $10,000 in a **Large new lots - confirm source** section, so you can diff --git a/docs/reference/cli/import.md b/docs/reference/cli/import.md index 507a090..6f471e6 100644 --- a/docs/reference/cli/import.md +++ b/docs/reference/cli/import.md @@ -5,40 +5,63 @@ for managed accounts (direct-indexing baskets, accounts you don't track at lot granularity). ``` -Usage: zfin -p import (--fidelity FILE | --schwab FILE | --wells-fargo FILE [--account NAME]) [-y] +Usage: zfin -p import (--fidelity FILE | --schwab FILE | --wells-fargo FILE) [-y] ``` -Each run **replaces** the target portfolio file with synthetic lots -- -one per (account, symbol) -- drawn from the export. Per-buy history is -lost; git serves as the file-level history. +Each run **replaces** the target portfolio file with lots drawn from the +export: + +- **Fidelity and Schwab** exports are per-position, so each (account, + symbol) becomes one synthetic lot. Per-buy history is lost; git serves + as the file-level history. +- **Wells Fargo** exports list every tax lot, so each WF lot becomes a + lot with its real trade date and cost. One WF file covers every + account in the household. **Re-import merge:** when the target already exists, lots still present -in the new export keep their prior `open_date`, `open_price`, and -`note::`, so trailing-return and ST/LT classifications stay stable and -`git diff` flags only genuine brokerage changes. New positions get an -`open_date::1970-01-01` sentinel; disappeared positions are dropped. -Hand-edited fields (`price::`, `ticker::`) are **not** preserved. +in the new export keep their prior `note::` and every hand-edited field +(`ticker::`, `label::`, `price::`, `price_ratio::`, `drip::`, ...), so +`git diff` flags only genuine brokerage changes. A lot whose export row +has no buy date (every Fidelity/Schwab position, and a Wells Fargo +`Intra-Day` fund) also keeps its prior `open_date` and `open_price`; new +ones get an `open_date::1970-01-01` sentinel. Disappeared positions are +dropped. ## Options -| Flag | Effect | -|--------------------------|--------------------------------------------------------------------------------------| -| `-p, --portfolio ` | Target file (a single concrete path, not a glob). **Required.** | -| `--fidelity ` | Fidelity positions CSV. | -| `--schwab ` | Schwab per-account positions CSV. | -| `--wells-fargo ` | Wells Fargo positions paste (`-` for stdin). | -| `--account ` | (Wells Fargo only) account to attribute lots to; must match an `accounts.srf` entry. | -| `-y, --yes` | Don't prompt before overwriting an existing file. | +| Flag | Effect | +|--------------------------|-----------------------------------------------------------------| +| `-p, --portfolio ` | Target file (a single concrete path, not a glob). **Required.** | +| `--fidelity ` | Fidelity positions CSV. | +| `--schwab ` | Schwab per-account positions CSV. | +| `--wells-fargo ` | Wells Fargo positions spreadsheet (`-` for stdin). See below. | +| `-y, --yes` | Don't prompt before overwriting an existing file. | Account resolution needs an [`accounts.srf`](../config/accounts-srf.md) next to the target with `institution::` + `account_number::` entries matching the export; import refuses to write when an export account is unmapped. +A target that doesn't exist yet is created in the current directory, so +run a first import from the portfolio's own directory. + +## Wells Fargo + +On the WF site, download the positions spreadsheet with **Download +Type** "Portfolio-Expanded Detail", **Portfolio View** "Positions", for +all brokerage accounts. It saves as `WFA_Portfolio_Positions_*.xls`. + +- "Portfolio-Collapsed Detail" has no per-lot rows; import rejects it. +- Each row names its account as `*1234`; map it with + `institution::wells_fargo,account_number::1234`. +- Cash is one lot per account: cash balance, sweep, and accrued + interest combined, matching WF's own cash total. + ## Example ```bash zfin -p portfolio_managed.srf import --fidelity ~/Downloads/Portfolio_Positions.csv +zfin -p portfolio_wells_fargo.srf import --wells-fargo ~/Downloads/WFA_Portfolio_Positions_100326_1247.xls ``` ## See also diff --git a/docs/reference/config/accounts-srf.md b/docs/reference/config/accounts-srf.md index 1a510ab..58a0bd7 100644 --- a/docs/reference/config/accounts-srf.md +++ b/docs/reference/config/accounts-srf.md @@ -28,8 +28,8 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0 |-----------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------| | `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. | | `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. | -| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. | -| `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. | +| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by `zfin audit` and `zfin import` to match export rows. | +| `account_number` | string | No | -- | Matched with `institution` against export rows (WF: `1234` for `*1234`). Use a placeholder, not a full real number. | | `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. | | `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). | | `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). | diff --git a/src/Date.zig b/src/Date.zig index 841e5e7..4e19fa8 100644 --- a/src/Date.zig +++ b/src/Date.zig @@ -81,6 +81,20 @@ pub fn parse(str: []const u8) !Date { return fromYmd(y, m, d); } +/// Parse US "MM/DD/YYYY" format, as brokerage exports print dates. +/// Exactly two-digit month and day and a four-digit year; the month +/// and day are range-checked against the calendar (so `02/30/2026` +/// is rejected rather than rolled into March), because the input +/// comes from external files rather than zfin's own serializer. +pub fn parseMdy(str: []const u8) !Date { + if (str.len != 10 or str[2] != '/' or str[5] != '/') return error.InvalidDateFormat; + const m = std.fmt.parseInt(u8, str[0..2], 10) catch return error.InvalidDateFormat; + const d = std.fmt.parseInt(u8, str[3..5], 10) catch return error.InvalidDateFormat; + const y = std.fmt.parseInt(i16, str[6..10], 10) catch return error.InvalidDateFormat; + if (m < 1 or m > 12 or d < 1 or d > daysInMonth(y, m)) return error.InvalidDateFormat; + return fromYmd(y, m, d); +} + /// Hook for srf coercion via `FieldIterator.to(T, ...)`. Returns a /// `CoercionResult(Date)` with `require_free_original = true` so SRF /// frees the consumed source string after parsing. @@ -645,3 +659,29 @@ test "parse error cases" { try std.testing.expectError(error.InvalidDateFormat, Date.parse("20240115")); // no dashes try std.testing.expectError(error.InvalidDateFormat, Date.parse("2024/01/15")); // wrong separator } + +test "parseMdy" { + try std.testing.expect(Date.fromYmd(2026, 10, 3).eql(try Date.parseMdy("10/03/2026"))); + try std.testing.expect(Date.fromYmd(2024, 2, 29).eql(try Date.parseMdy("02/29/2024"))); // leap day + try std.testing.expect(Date.fromYmd(1999, 12, 31).eql(try Date.parseMdy("12/31/1999"))); +} + +test "parseMdy error cases" { + const bad = [_][]const u8{ + "2026-10-03", // ISO, not US + "10/3/2026", // single-digit day + "10-03-2026", // wrong separator + "10/03/26", // two-digit year + "aa/03/2026", + "10/bb/2026", + "10/03/yyyy", + "00/10/2026", // month 0 + "13/01/2026", // month 13 + "01/00/2026", // day 0 + "02/30/2026", // past month end + "02/29/2026", // not a leap year + "Intra-Day", // what brokerages print when there is no date + "", + }; + for (bad) |s| try std.testing.expectError(error.InvalidDateFormat, Date.parseMdy(s)); +} diff --git a/src/analytics/reconcile.zig b/src/analytics/reconcile.zig index b7e36e7..3d78b95 100644 --- a/src/analytics/reconcile.zig +++ b/src/analytics/reconcile.zig @@ -6,10 +6,12 @@ //! cdLotAllowance, findAbsentAccounts, ...) //! - `schwab` Schwab positions CSV + summary reconcilers //! - `fidelity` Fidelity positions CSV reconciler +//! - `wells_fargo` Wells Fargo positions spreadsheet reconciler pub const common = @import("reconcile/common.zig"); pub const schwab = @import("reconcile/schwab.zig"); pub const fidelity = @import("reconcile/fidelity.zig"); +pub const wells_fargo = @import("reconcile/wells_fargo.zig"); // ── Flat convenience re-exports ────────────────────────────── pub const value_tolerance = common.value_tolerance; @@ -35,3 +37,4 @@ pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies; pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions; pub const reconcileFidelity = fidelity.reconcile; +pub const reconcileWellsFargo = wells_fargo.reconcile; diff --git a/src/analytics/reconcile/wells_fargo.zig b/src/analytics/reconcile/wells_fargo.zig new file mode 100644 index 0000000..01cfda5 --- /dev/null +++ b/src/analytics/reconcile/wells_fargo.zig @@ -0,0 +1,182 @@ +//! Wells Fargo reconciler (pure compute). +//! +//! WF's positions export is one spreadsheet for the whole household +//! (see `brokerage/wells_fargo.zig`). This module wires its positions +//! into the shared per-account comparison engine in `common.zig`; the +//! export lists one row per tax lot, which `compareAccounts` sums per +//! (account, symbol) like any multi-row position. The ANSI display +//! lives in `commands/audit/`. + +const std = @import("std"); +const biff8 = @import("biff8"); +const zfin = @import("../../root.zig"); +const analysis = @import("../../analytics/analysis.zig"); +const Date = @import("../../Date.zig"); +const common = @import("common.zig"); +const wf_parser = @import("../../brokerage/wells_fargo.zig"); + +/// Reconcile the positions sheet against the portfolio. Returns owned +/// `AccountComparison` results (free each `.comparisons` slice, then +/// the results slice). String fields in the results borrow from +/// `sheet`, which must outlive them. +pub fn reconcile( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + sheet: *const biff8.Sheet, + account_map: analysis.AccountMap, + prices: std.StringHashMap(f64), + as_of: Date, +) ![]common.AccountComparison { + const positions = try wf_parser.parsePositions(allocator, sheet); + // Result strings borrow from the sheet, not from this slice. + defer allocator.free(positions); + return common.compareAccounts(allocator, portfolio, positions, account_map, wf_parser.institution, prices, as_of); +} + +/// A reconciliation of an export file, owning the decoded workbook +/// its results borrow from. +pub const Reconciled = struct { + workbook: biff8.Workbook, + results: []common.AccountComparison, + + pub fn deinit(self: *Reconciled, allocator: std.mem.Allocator) void { + for (self.results) |r| allocator.free(r.comparisons); + allocator.free(self.results); + self.workbook.deinit(); + } + + /// True when at least one of the export's accounts is in + /// `accounts.srf`. An export with none is almost certainly another + /// portfolio's (one household's download sitting in a shared + /// Downloads directory), so auto-discovery skips it rather than + /// reporting every account as a discrepancy. + pub fn mapsAnyAccount(self: Reconciled) bool { + for (self.results) |r| { + if (r.account_name.len > 0) return true; + } + return false; + } +}; + +/// Decode an export file and reconcile it. Errors are the decode's +/// (`wf_parser.ExportError`) and allocation failure; use +/// `wf_parser.exportHint` to explain the former. +pub fn reconcileXls( + allocator: std.mem.Allocator, + portfolio: zfin.Portfolio, + bytes: []const u8, + account_map: analysis.AccountMap, + prices: std.StringHashMap(f64), + as_of: Date, +) wf_parser.ExportError!Reconciled { + var workbook = try biff8.Workbook.parse(allocator, bytes); + errdefer workbook.deinit(); + const sheet = try wf_parser.positionsSheet(&workbook); + const results = try reconcile(allocator, portfolio, sheet, account_map, prices, as_of); + return .{ .workbook = workbook, .results = results }; +} + +// ---- Tests ---- + +const Lot = @import("../../models/portfolio.zig").Lot; +const Portfolio = @import("../../models/portfolio.zig").Portfolio; +const Cell = biff8.Cell; + +fn txt(s: []const u8) Cell { + return .{ .text = s }; +} + +fn num(v: f64) Cell { + return .{ .number = v }; +} + +const header = [_]Cell{ txt("Description"), txt("Symbol"), txt("Account Number"), txt("Market Value"), txt("Shares"), txt("Total Cost"), txt("Trade Date") }; + +/// Placeholder household: VTI held as two lots in *1234, plus cash. +const rows = [_][]const Cell{ + &.{ txt("Description"), txt("Account Number"), txt("Market Value") }, + &.{ txt("Cash Balance"), txt("*1234"), num(40) }, + &.{ txt("Bank Deposit Sweep"), txt("*1234"), num(60) }, + &.{txt("Total Cash/Cash Alternatives & Margin")}, + &header, + &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), num(6000), num(20), num(4500), txt("Detail") }, + &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), num(3600), num(12), num(2400), txt("02/03/2022") }, + &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), num(2400), num(8), num(2100), txt("09/09/2026") }, + &.{txt("Total ETFs")}, +}; +const test_sheet: biff8.Sheet = .{ .name = wf_parser.sheet_name, .rows = &rows }; + +test "reconcile: per-lot rows and cash rows sum per account and match the portfolio" { + const allocator = std.testing.allocator; + + // The portfolio may hold the position as one lot or as lots of its + // own; either way 20 VTI @ $300 plus $100 of cash matches. + var lots = [_]Lot{ + .{ .symbol = "VTI", .shares = 20, .open_date = Date.fromYmd(2022, 2, 3), .open_price = 225, .account = "Sample IRA" }, + .{ .symbol = "", .shares = 100, .open_date = Date.epoch, .open_price = 1, .account = "Sample IRA", .security_type = .cash }, + }; + const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VTI", 300.0); + + const results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expectEqualStrings("Sample IRA", results[0].account_name); + try std.testing.expectEqualStrings("*1234", results[0].brokerage_name); + try std.testing.expect(!results[0].has_discrepancies); + // VTI consolidated into one comparison, cash into another. + try std.testing.expectEqual(@as(usize, 2), results[0].comparisons.len); +} + +test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf" { + const allocator = std.testing.allocator; + const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "9012" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + var rec: Reconciled = .{ + .workbook = .{ .arena = std.heap.ArenaAllocator.init(allocator), .sheets = &.{} }, + .results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)), + }; + defer rec.deinit(allocator); + try std.testing.expect(!rec.mapsAnyAccount()); + + // With the export's account mapped, it is ours. + entries[0].account_number = "1234"; + var mapped: Reconciled = .{ + .workbook = .{ .arena = std.heap.ArenaAllocator.init(allocator), .sheets = &.{} }, + .results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)), + }; + defer mapped.deinit(allocator); + try std.testing.expect(mapped.mapsAnyAccount()); +} + +test "reconcileXls: decode errors surface with their names" { + const allocator = std.testing.allocator; + const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; + var entries = [_]analysis.AccountTaxEntry{}; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + try std.testing.expectError( + error.NotCompoundFile, + reconcileXls(allocator, portfolio, "Symbol,Quantity\nVTI,1\n", acct_map, prices, Date.fromYmd(2026, 10, 3)), + ); +} diff --git a/src/brokerage.zig b/src/brokerage.zig index 3e145bd..ce7bf81 100644 --- a/src/brokerage.zig +++ b/src/brokerage.zig @@ -1,10 +1,11 @@ //! Brokerage export parsers, grouped for downstream consumers. //! -//! Each broker module parses its export (positions CSV, and for Schwab -//! an account-summary paste) into the normalized `BrokeragePosition` -//! shape in `types.zig`. Pure functions: `(allocator, data) -> parsed`, -//! no IO. The reconciliation layer (`analytics/reconcile`) and finrev -//! consume these. +//! Each broker module parses its export (positions CSV, for Schwab +//! an account-summary paste, for Wells Fargo a positions spreadsheet) +//! into the normalized `BrokeragePosition` shape in `types.zig`. Pure +//! functions: `(allocator, data) -> parsed`, no IO. The +//! reconciliation layer (`analytics/reconcile`) and finrev consume +//! these. pub const types = @import("brokerage/types.zig"); pub const schwab = @import("brokerage/schwab.zig"); diff --git a/src/brokerage/discover.zig b/src/brokerage/discover.zig index f9c0c6d..0487d61 100644 --- a/src/brokerage/discover.zig +++ b/src/brokerage/discover.zig @@ -14,9 +14,12 @@ //! keeps this module free of a buffer-lifetime contract. const std = @import("std"); +const biff8 = @import("biff8"); +const wells_fargo = @import("wells_fargo.zig"); -/// Size ceiling for non-CSV candidates. A CSV is exempt because a positions -/// export legitimately gets large; anything else this big is not an export. +/// Size ceiling for candidates that are not spreadsheet exports. CSV and +/// XLS are exempt because a positions export legitimately gets large; +/// anything else this big is not an export. const max_size_non_csv = 512 * 1024; /// Type of a discovered brokerage file. @@ -24,6 +27,7 @@ pub const BrokerFileKind = enum { fidelity_csv, schwab_csv, schwab_summary, + wells_fargo_xls, }; /// A discovered brokerage file ready for reconciliation. @@ -41,6 +45,14 @@ pub const DiscoveredFile = struct { /// stable self-identifying marker (rather than the column header) means a /// format tweak surfaces as a specific parse error, not a misroute. pub fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind { + // Wells Fargo positions spreadsheet: a binary .xls whose one sheet is + // named `WFA_Positions`. The sheet name is stored as plain ASCII in + // the workbook's sheet directory, so a byte search finds it without + // decoding the file; the compound-file signature keeps a text file + // that merely mentions the name from matching. + if (biff8.isCompoundFile(data) and std.mem.indexOf(u8, data, wells_fargo.sheet_name) != null) + return .wells_fargo_xls; + // Strip optional UTF-8 BOM const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF) data[3..] @@ -104,9 +116,9 @@ pub fn brokerFiles( const age_s = now_s - mtime_s; if (age_s > max_age_s) continue; - // Check if it's a CSV (no size limit) or non-CSV (size limit applies) - const is_csv = std.mem.endsWith(u8, entry.name, ".csv") or std.mem.endsWith(u8, entry.name, ".CSV"); - if (!is_csv and stat.size > max_size_non_csv) continue; + // Spreadsheet exports (no size limit) vs everything else (size limit applies) + const is_export_format = isSpreadsheetName(entry.name); + if (!is_export_format and stat.size > max_size_non_csv) continue; // Read and detect content type const data = dir.readFileAlloc(io, entry.name, allocator, .limited(10 * 1024 * 1024)) catch continue; @@ -124,6 +136,32 @@ pub fn brokerFiles( return results.toOwnedSlice(allocator); } +/// `.csv` or `.xls`, either case: the formats brokerages export +/// positions in, exempt from the non-export size ceiling. +fn isSpreadsheetName(name: []const u8) bool { + const ext = std.fs.path.extension(name); + return std.ascii.eqlIgnoreCase(ext, ".csv") or std.ascii.eqlIgnoreCase(ext, ".xls"); +} + +test "isSpreadsheetName" { + try std.testing.expect(isSpreadsheetName("positions.csv")); + try std.testing.expect(isSpreadsheetName("POSITIONS.CSV")); + try std.testing.expect(isSpreadsheetName("WFA_Portfolio_Positions.xls")); + try std.testing.expect(isSpreadsheetName("export.XLS")); + try std.testing.expect(!isSpreadsheetName("summary.txt")); + try std.testing.expect(!isSpreadsheetName("book.xlsx")); + try std.testing.expect(!isSpreadsheetName("csv")); +} + +test "detectBrokerFileKind: wells fargo xls identified by signature + sheet name" { + const sig = [_]u8{ 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }; + try std.testing.expectEqual(BrokerFileKind.wells_fargo_xls, detectBrokerFileKind(&sig ++ "....WFA_Positions....").?); + // Some other legacy Office file: compound file, no WF sheet. + try std.testing.expect(detectBrokerFileKind(&sig ++ "....Sheet1....") == null); + // A text file that mentions the sheet name is not a workbook. + try std.testing.expect(detectBrokerFileKind("notes about WFA_Positions") == null); +} + test "detectBrokerFileKind: fidelity csv identified by legal footer" { // Detection keys on the self-identifying legal-entity footer, not the // column header (which Fidelity has already re-cased). A realistic diff --git a/src/brokerage/types.zig b/src/brokerage/types.zig index 46e386c..679a10e 100644 --- a/src/brokerage/types.zig +++ b/src/brokerage/types.zig @@ -30,6 +30,12 @@ //! shape - exactly what the audit reconciler needs to compare //! against the portfolio's mapped view. //! +//! Wells Fargo's positions export is the exception to "no buy date": +//! it lists every tax lot with its trade date, so its parser builds +//! real `Lot`s for import directly (`wells_fargo.parseLots`) rather +//! than going through a position. `lotFromPosition` below is the one +//! conversion rule both paths share. +//! //! ## Memory & lifetime contract //! //! All string fields in records produced by the per-broker parsers @@ -39,6 +45,9 @@ //! allocator. const std = @import("std"); +const Date = @import("../Date.zig"); +const Lot = @import("../models/portfolio.zig").Lot; +const LotType = @import("../models/portfolio.zig").LotType; // ── Brokerage position (normalized from any source) ───────── @@ -56,6 +65,44 @@ pub const BrokeragePosition = struct { is_cash: bool, }; +/// The lot an export row becomes on import, for portfolio account +/// `account`: +/// +/// - cash: `security_type::cash`, shares = the dollar value at a +/// $1.00 open price (the convention audit and snapshot use for +/// cash rows) +/// - otherwise: shares from the row, open price = cost basis per +/// share, falling back to current value per share, else 0 +/// - `open_date` is `Date.epoch`, import's "unknown" sentinel; a +/// caller that knows the buy date overwrites it +/// +/// A non-cash row without a quantity is malformed export data; it +/// becomes a harmless 0-share lot the user will see in `git diff`. +/// +/// Strings are borrowed from `pos` and `account`. +pub fn lotFromPosition(pos: BrokeragePosition, account: []const u8) Lot { + if (pos.is_cash) { + return .{ + .symbol = pos.symbol, + .shares = pos.quantity orelse pos.current_value orelse 0, + .open_date = Date.epoch, + .open_price = 1.0, + .account = account, + .security_type = .cash, + }; + } + const shares = pos.quantity orelse 0; + const total: ?f64 = pos.cost_basis orelse pos.current_value; + return .{ + .symbol = pos.symbol, + .shares = shares, + .open_date = Date.epoch, + .open_price = if (total) |t| (if (shares > 0) t / shares else 0) else 0, + .account = account, + .security_type = .stock, + }; +} + // ── Dollar-string parsing ──────────────────────────────────── /// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00". @@ -132,3 +179,36 @@ test "isUnitPriceCash: unparseable inputs return false" { try std.testing.expect(!isUnitPriceCash("$1.00", "")); try std.testing.expect(!isUnitPriceCash("N/A", "$1.00")); } + +fn testPosition(quantity: ?f64, value: ?f64, cost: ?f64, is_cash: bool) BrokeragePosition { + return .{ .account_number = "1234", .account_name = "", .symbol = "SAMPLE", .description = "", .quantity = quantity, .current_value = value, .cost_basis = cost, .is_cash = is_cash }; +} + +test "lotFromPosition: stock open price is cost basis per share" { + const lot = lotFromPosition(testPosition(100, 17500, 12000, false), "Sample Brokerage"); + try std.testing.expectEqualStrings("SAMPLE", lot.symbol); + try std.testing.expectEqualStrings("Sample Brokerage", lot.account.?); + try std.testing.expectEqual(LotType.stock, lot.security_type); + try std.testing.expectEqual(@as(f64, 100), lot.shares); + try std.testing.expectEqual(@as(f64, 120), lot.open_price); + try std.testing.expect(Date.epoch.eql(lot.open_date)); +} + +test "lotFromPosition: no cost basis falls back to value per share, then 0" { + try std.testing.expectEqual(@as(f64, 400), lotFromPosition(testPosition(10, 4000, null, false), "A").open_price); + try std.testing.expectEqual(@as(f64, 0), lotFromPosition(testPosition(10, null, null, false), "A").open_price); + // No quantity: a harmless zero-share lot rather than a divide by zero. + const no_qty = lotFromPosition(testPosition(null, 4000, 3000, false), "A"); + try std.testing.expectEqual(@as(f64, 0), no_qty.shares); + try std.testing.expectEqual(@as(f64, 0), no_qty.open_price); +} + +test "lotFromPosition: cash is dollars at a $1 open price" { + const lot = lotFromPosition(testPosition(null, 5000, null, true), "A"); + try std.testing.expectEqual(LotType.cash, lot.security_type); + try std.testing.expectEqual(@as(f64, 5000), lot.shares); + try std.testing.expectEqual(@as(f64, 1.0), lot.open_price); + // A cash row that reports a quantity uses it. + try std.testing.expectEqual(@as(f64, 4999), lotFromPosition(testPosition(4999, 5000, null, true), "A").shares); + try std.testing.expectEqual(@as(f64, 0), lotFromPosition(testPosition(null, null, null, true), "A").shares); +} diff --git a/src/brokerage/wells_fargo.zig b/src/brokerage/wells_fargo.zig index 4391301..933ca57 100644 --- a/src/brokerage/wells_fargo.zig +++ b/src/brokerage/wells_fargo.zig @@ -1,1320 +1,711 @@ -//! Wells Fargo paste parser. +//! Wells Fargo Advisors positions export. //! -//! 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: +//! The WF portal's spreadsheet download, with "Download Type" set to +//! **Portfolio-Expanded Detail**, "Portfolio View" set to **Positions**, +//! for all brokerage accounts. It is a legacy binary `.xls` (BIFF8), +//! decoded with the `biff8` package, holding one sheet, `WFA_Positions`, +//! for the whole household: //! //! ``` -//! 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 +//! Priced as of Close on MM/DD/YYYY +//! Account Number: HOUSEHOLD +//! Cash/Cash Alternatives & Margin <- cash section +//! Description | Account Number | Market Value +//! Cash Balance | *1234 | ... +//! Bank Deposit Sweep | *1234 | ... +//! Accrued Money Market & Sweep Interest | Detail | ... (household subtotal) +//! Accrued Money Market & Sweep Interest | *1234 | ... +//! Total Cash/Cash Alternatives & Margin +//! Stocks / Common Stock <- section titles +//! Description | Symbol | Account Number | ... (34 columns) +//! +//! Total ... +//! ETFs, Mutual Funds <- same shape, own headers +//! Total Portfolio +//! //! ``` //! -//! 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. +//! Security rows nest three levels deep, distinguished by two cells: //! -//! ## Limitations +//! | Account Number | Trade Date | Row is | +//! |----------------|-----------------|----------------------------------------| +//! | `Detail` | (any) | household total for a symbol held in several accounts | +//! | `*1234` | `Detail...` | one account's total for a multi-lot symbol | +//! | `*1234` | a date, ... | one tax lot (the only rows that count) | //! -//! 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. +//! A symbol held as a single lot in an account has only the lot row. +//! Every per-account total is checked against the lots under it, so a +//! layout change (or a "Collapsed Detail" download, which has totals but +//! no lots) fails loudly instead of importing the wrong shares. //! -//! 2. WF pastes don't carry an account identifier; the import -//! command resolves the account separately (filename inference -//! + `--account` override + accounts.srf lookup). +//! Lot trade dates are `MM/DD/YYYY`, suffixed `nc` for lots whose cost +//! WF does not report to the IRS. Funds with same-day activity show +//! `Intra-Day` instead, with no trade date or cost; those lots get +//! `Date.epoch` (import's "unknown" sentinel) and no cost basis, so +//! import falls back exactly as it does for an export without dates. //! -//! 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. +//! Columns are located by header name, not position. WF suffixes some +//! headers with a footnote digit (`Trade Date1`); those are stripped +//! before matching. //! -//! 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. +//! Account identity: each row carries the account as `*NNNN`, the last +//! four digits. `accounts.srf` entries with `institution::wells_fargo` +//! map them through `account_number::NNNN`. const std = @import("std"); const builtin = @import("builtin"); -const portfolio_mod = @import("../models/portfolio.zig"); +const biff8 = @import("biff8"); +const Date = @import("../Date.zig"); +const Lot = @import("../models/portfolio.zig").Lot; +const LotType = @import("../models/portfolio.zig").LotType; +const isMoneyMarketSymbol = @import("../models/portfolio.zig").isMoneyMarketSymbol; const types = @import("types.zig"); const analysis = @import("../analytics/analysis.zig"); const BrokeragePosition = types.BrokeragePosition; -const parseDollarAmount = types.parseDollarAmount; +const Cell = biff8.Cell; +const Sheet = biff8.Sheet; +const log = std.log.scoped(.wells_fargo); /// 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. +/// (`institution::wells_fargo`). pub const institution = "wells_fargo"; -/// Parse a Wells Fargo paste into BrokeragePosition slices. +/// The one sheet in a positions export. +pub const sheet_name = "WFA_Positions"; + +pub const Error = error{ + /// No `WFA_Positions` sheet: some other spreadsheet, or a WF + /// download other than the positions view. + NotPositionsExport, + /// A section header is missing a column the parser reads. + UnexpectedHeader, + /// An account cell is not `*` followed by digits. + UnexpectedAccountNumber, + /// A lot's trade-date cell is neither a date nor a placeholder WF is + /// known to print (`Intra-Day`, `N/A`). + UnexpectedTradeDate, + /// A lot or cash row lacks its share count or market value. + MissingValue, + /// A per-account total disagrees with the lots listed under it. + InconsistentExport, + OutOfMemory, +}; + +/// The positions sheet of a parsed workbook. +pub fn positionsSheet(wb: *const biff8.Workbook) Error!*const Sheet { + return wb.sheet(sheet_name) orelse error.NotPositionsExport; +} + +/// Anything reading an export file can fail with: the spreadsheet +/// decode, then the sheet layout. +pub const ExportError = biff8.ParseError || Error; + +/// What to tell the user about a failed export, beyond the error's +/// name. Most failures mean a different choice on WF's download page, +/// so name the right one. Lines are indented to sit under an +/// `Error: ...` line; empty when there is nothing useful to add. +pub fn exportHint(err: ExportError) []const u8 { + return switch (err) { + error.NotCompoundFile, + error.NoWorkbookStream, + error.UnsupportedBiffVersion, + error.NotPositionsExport, + => " Expected the WF positions spreadsheet: Download Type \"Portfolio-Expanded Detail\",\n" ++ + " Portfolio View \"Positions\", all brokerage accounts.\n", + error.InconsistentExport, + error.UnexpectedTradeDate, + => " Download Type must be \"Portfolio-Expanded Detail\" (per-lot rows), not \"Collapsed\".\n", + error.Truncated => " The file ends early; download it again.\n", + else => "", + }; +} + +/// One `BrokeragePosition` per tax lot, plus one per cash row (cash +/// balance, sweep, accrued interest: `symbol` empty, `is_cash`). Rows +/// for the same account and symbol are left separate; the reconciler +/// consolidates them. /// -/// 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; +/// `account_number` is the digits (`1234`); `account_name` is WF's own +/// `*1234`, which is all the export says about the account. Strings +/// borrow from `sheet`. Caller frees the returned slice. +pub fn parsePositions(allocator: std.mem.Allocator, sheet: *const Sheet) Error![]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 a popup anchor. WF - // emits a `, popup` (or `,popup`) suffix as part of the - // symbol column's hover affordance; it's a very stable - // record anchor. - // - // We do NOT stop at intermediate totals lines (`Stocks Total`, - // `ETFs Total`). The WF holdings page splits positions into - // multiple sections (Stocks, ETFs, Bonds, ...), each terminated - // by its own totals line; the second/third section's records - // appear AFTER an intermediate totals line and we want to - // capture them. The only structural boundary between - // positions and the (optional) cash table is the - // `Cash, Cash Alternatives and Margin` header, which we - // detect explicitly below. - const cash_header = "Cash, Cash Alternatives and Margin"; - var i: usize = 0; - while (i < staged.items.len) : (i += 1) { - const line = staged.items[i]; - if (std.mem.eql(u8, line, cash_header)) 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; - - // Optional trade-date column: `Multiple(N)` or - // `MM/DD/YYYY`. Some paste shapes (529 plans, mutual-fund - // accounts) omit this column entirely and go straight from - // description to shares. Detect the difference by peeking: - // if the next non-empty line parses as a pure decimal - // (the shares column), don't consume it as trade-date. - var peek_idx = cur; - const peek_line = nextNonEmpty(staged.items, &peek_idx) orelse break; - const peek_is_shares = parseSharesAmount(peek_line) != null; - if (!peek_is_shares) { - // Trade-date column present - consume it. - cur = peek_idx; - } - // else: leave `cur` at the original position; the shares - // step below will consume `peek_line` as the shares value. - - // Shares: integer or decimal with optional thousands - // commas, no $. - const shares_text = nextNonEmpty(staged.items, &cur) orelse break; - const shares = parseSharesAmount(shares_text) orelse continue; - - // Avg cost: either `@ $price` (with cost-basis history) - // or `N/A` (529 plans / managed accounts where WF doesn't - // surface a cost basis). When `N/A`, leave avg_cost null - // and let the import path synthesize cost = market value. - const cost_text = nextNonEmpty(staged.items, &cur) orelse break; - const avg_cost: ?f64 = if (std.mem.startsWith(u8, cost_text, "@ ")) - parseDollarAmount(cost_text[2..]) orelse continue - else if (std.mem.eql(u8, cost_text, "N/A")) - null - else - 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, - // When avg_cost is null (WF showed `N/A`), - // `synthesizeLots` will fall back to current_value / - // shares, producing a synthetic open_price equal to - // today's price. That's the right behavior for - // managed accounts where WF doesn't surface cost - // basis - gain/loss is unknown anyway. - .cost_basis = if (avg_cost) |c| shares * c else null, - .is_cash = portfolio_mod.isMoneyMarketSymbol(symbol), - }); - } - - // ── Cash section (optional) ───────────────────────────── - // - // The user may include the WF "Cash, Cash Alternatives and - // Margin" table at the end of the paste. The shape is: - // - // Cash, Cash Alternatives and Margin - // Cash alternatives and margin table has been sorted ... - // - // ← e.g. "Sample IRA *1234" - // $14,216.88 ← per-account cash balance - // - // Cash Total - // $14,216.88 ← grand total (skip) - // - // We capture the per-account balance as a synthetic cash - // position with empty `symbol`. The downstream - // `applyAccountToPositions` stamps the account fields, and - // `synthesizeLots` emits a `security_type::cash` lot. - // - // Multiple per-account rows are tolerated (one cash position - // per row); in practice the WF paste flow is single-account so - // there's typically one. Stops at "Cash Total". - { - // After the positions loop, `i` is either at the cash - // header (if the user included the cash table) or at the - // end of the staged array (no cash table). Walk forward - // looking for the header in case it appears after extra - // intermediate lines, then parse account/amount pairs. - var j: usize = i; - while (j < staged.items.len and !std.mem.eql(u8, staged.items[j], cash_header)) : (j += 1) {} - if (j < staged.items.len) { - // Skip the header + the explanatory subtitle line. - j += 1; // past "Cash, Cash Alternatives and Margin" - if (j < staged.items.len and std.mem.startsWith(u8, staged.items[j], "Cash alternatives")) { - j += 1; - } - // Walk rows. Each row is an account name (anything - // non-blank, non-Total) followed by a `$AMOUNT` line. - // Stop at "Cash Total" (the grand-total marker). - while (j < staged.items.len) : (j += 1) { - const row = staged.items[j]; - if (row.len == 0) continue; - if (std.mem.eql(u8, row, "Cash Total")) break; - // Account-name row. Look ahead for the dollar amount. - var k: usize = j + 1; - while (k < staged.items.len and staged.items[k].len == 0) : (k += 1) {} - if (k >= staged.items.len) break; - const amount_text = staged.items[k]; - // The dollar amount must start with `$` to count as - // a cash balance. Any other shape (e.g. "Cash Total" - // appearing here would mean a malformed paste) -> skip. - if (amount_text.len == 0 or amount_text[0] != '$') continue; - const cash_amount = parseDollarAmount(amount_text) orelse continue; - try positions.append(allocator, .{ - .account_number = "", - .account_name = "", - .symbol = "", - .description = "Cash", - .quantity = null, - .current_value = cash_amount, - .cost_basis = null, - .is_cash = true, - }); - j = k; // resume past the consumed amount line - } - } - } - + try walk(allocator, sheet, &positions, null); return positions.toOwnedSlice(allocator); } -/// True when `line` is a record-start anchor like `GSLC , popup` -/// or `XOM,popup`. The trailing `popup` is the stable signal - WF's -/// hover affordance - and the comma immediately precedes it (with -/// optional whitespace either side, which varies between paste -/// shapes for stocks vs ETFs). -fn isPopupAnchor(line: []const u8) bool { - if (!std.mem.endsWith(u8, line, "popup")) return false; - // The portion before `popup` must end with a comma (with - // optional whitespace). This rejects e.g. "popup" alone or - // "something popup" without a separator. - const before = line[0 .. line.len - "popup".len]; - const trimmed = std.mem.trimEnd(u8, before, &.{ ' ', '\t' }); - return std.mem.endsWith(u8, trimmed, ","); +/// The export as portfolio lots: one per WF tax lot, with its real +/// trade date and cost, and one cash lot per account summing every +/// cash row. Built with `types.lotFromPosition`, so the cash and +/// cost conventions match every other import source. Lots with no +/// trade date keep `Date.epoch`. +/// +/// `note` is left null for the caller. Every string is allocated +/// against `allocator`; free each lot's `symbol` and `account`, then +/// the slice. +pub fn parseLots( + allocator: std.mem.Allocator, + sheet: *const Sheet, + account_map: analysis.AccountMap, +) (Error || error{UnmappedAccount})![]Lot { + var positions: std.ArrayList(BrokeragePosition) = .empty; + defer positions.deinit(allocator); + var open_dates: std.ArrayList(?Date) = .empty; + defer open_dates.deinit(allocator); + try walk(allocator, sheet, &positions, &open_dates); + + var lots: std.ArrayList(Lot) = .empty; + errdefer { + for (lots.items) |lot| { + allocator.free(lot.symbol); + if (lot.account) |a| allocator.free(a); + } + lots.deinit(allocator); + } + + for (positions.items, open_dates.items) |pos, open_date| { + const account = account_map.findByInstitutionAccount(institution, pos.account_number) orelse + return error.UnmappedAccount; + + // Cash-section rows fold into one cash lot per account, kept + // where that account's first cash row appeared. + if (pos.is_cash and pos.symbol.len == 0) { + const value = pos.current_value orelse 0; + for (lots.items) |*lot| { + if (lot.security_type == .cash and lot.symbol.len == 0 and std.mem.eql(u8, lot.account.?, account)) { + lot.shares += value; + break; + } + } else try appendOwned(allocator, &lots, types.lotFromPosition(pos, account)); + continue; + } + + var lot = types.lotFromPosition(pos, account); + if (open_date) |d| lot.open_date = d; + try appendOwned(allocator, &lots, lot); + } + return lots.toOwnedSlice(allocator); } -/// Extract the symbol from a popup anchor line. Returns null if -/// the line is the right shape but the symbol part is empty. -/// Accepts both `SYMBOL,popup` and `SYMBOL , popup` shapes. -fn popupSymbol(line: []const u8) ?[]const u8 { - if (!isPopupAnchor(line)) return null; - // Strip trailing `popup`, surrounding whitespace, and the - // separator comma to get the symbol token. - var end = line.len - "popup".len; - while (end > 0 and (line[end - 1] == ' ' or line[end - 1] == '\t')) end -= 1; - if (end == 0 or line[end - 1] != ',') return null; - end -= 1; // drop the comma - const symbol = std.mem.trim(u8, line[0..end], &.{ ' ', '\t' }); - if (symbol.len == 0) return null; - return symbol; +fn appendOwned(allocator: std.mem.Allocator, lots: *std.ArrayList(Lot), lot: Lot) !void { + var owned = lot; + owned.symbol = try allocator.dupe(u8, lot.symbol); + errdefer allocator.free(owned.symbol); + owned.account = try allocator.dupe(u8, lot.account.?); + errdefer allocator.free(owned.account.?); + try lots.append(allocator, owned); } -/// 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"); +// ---- Sheet walk ---- + +/// Column indices of a securities section, from its header row. +const Columns = struct { + description: usize, + symbol: usize, + account: usize, + shares: usize, + market_value: usize, + total_cost: usize, + trade_date: usize, +}; + +/// A per-account total row, held until the lots under it are summed. +const OpenTotal = struct { + row: usize, + account: []const u8, + symbol: []const u8, + shares: f64, + lot_shares: f64 = 0, +}; + +const Section = union(enum) { + none, + cash: struct { account: usize, market_value: usize }, + securities: Columns, +}; + +/// Visit every lot and cash row in sheet order, appending a position +/// for each (and its trade date, when `open_dates` is given). +fn walk( + allocator: std.mem.Allocator, + sheet: *const Sheet, + positions: *std.ArrayList(BrokeragePosition), + open_dates: ?*std.ArrayList(?Date), +) Error!void { + var section: Section = .none; + var open_total: ?OpenTotal = null; + + for (0..sheet.rows.len) |r| { + const first = trimmedText(sheet.cell(r, 0)); + + if (std.mem.eql(u8, first, "Description")) { + try closeTotal(&open_total); + section = try parseHeader(sheet, r); + continue; + } + if (std.mem.startsWith(u8, first, "Total")) { + try closeTotal(&open_total); + section = .none; + continue; + } + + switch (section) { + .none => {}, + .cash => |c| { + const account = trimmedText(sheet.cell(r, c.account)); + // Blank lines, and "Detail": the household subtotal + // of a cash line that appears in several accounts. + if (account.len == 0 or std.mem.eql(u8, account, "Detail")) continue; + try positions.append(allocator, .{ + .account_number = try accountDigits(account, r), + .account_name = account, + .symbol = "", + .description = first, + .quantity = null, + .current_value = try requireNumber(sheet.cell(r, c.market_value), r, "Market Value"), + .cost_basis = null, + .is_cash = true, + }); + if (open_dates) |d| try d.append(allocator, null); + }, + .securities => |cols| { + const symbol = trimmedText(sheet.cell(r, cols.symbol)); + const account = trimmedText(sheet.cell(r, cols.account)); + // Section titles ("Common Stock", "Open End") and blank + // lines have no symbol. + if (symbol.len == 0 or account.len == 0) continue; + + // Household total: the per-account rows that follow + // carry everything it sums. + if (std.mem.eql(u8, account, "Detail")) { + try closeTotal(&open_total); + continue; + } + + const trade = sheet.cell(r, cols.trade_date); + const shares = try requireNumber(sheet.cell(r, cols.shares), r, "Shares"); + + if (std.mem.startsWith(u8, trimmedText(trade), "Detail")) { + try closeTotal(&open_total); + open_total = .{ .row = r, .account = account, .symbol = symbol, .shares = shares }; + continue; + } + + // A lot. It belongs to the open total only if it is the + // same account and symbol; anything else ends that total. + if (open_total) |*t| { + if (std.mem.eql(u8, t.account, account) and std.mem.eql(u8, t.symbol, symbol)) { + t.lot_shares += shares; + } else try closeTotal(&open_total); + } + + const is_cash = isMoneyMarketSymbol(symbol); + try positions.append(allocator, .{ + .account_number = try accountDigits(account, r), + .account_name = account, + .symbol = symbol, + .description = trimmedText(sheet.cell(r, cols.description)), + // Money-market rows follow the cash convention every + // other parser uses: dollars, not shares. + .quantity = if (is_cash) null else shares, + .current_value = try requireNumber(sheet.cell(r, cols.market_value), r, "Market Value"), + .cost_basis = if (is_cash) null else sheet.cell(r, cols.total_cost).asNumber(), + .is_cash = is_cash, + }); + if (open_dates) |d| try d.append(allocator, try parseTradeDate(trade, r)); + }, + } + } + try closeTotal(&open_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; +/// Classify a header row: securities sections have a `Symbol` column, +/// the cash section does not. +fn parseHeader(sheet: *const Sheet, r: usize) Error!Section { + const has_symbol = findColumn(sheet, r, "Symbol") != null; + if (!has_symbol) { + return .{ .cash = .{ + .account = try requireColumn(sheet, r, "Account Number"), + .market_value = try requireColumn(sheet, r, "Market Value"), + } }; + } + return .{ .securities = .{ + .description = try requireColumn(sheet, r, "Description"), + .symbol = try requireColumn(sheet, r, "Symbol"), + .account = try requireColumn(sheet, r, "Account Number"), + .shares = try requireColumn(sheet, r, "Shares"), + .market_value = try requireColumn(sheet, r, "Market Value"), + .total_cost = try requireColumn(sheet, r, "Total Cost"), + .trade_date = try requireColumn(sheet, r, "Trade Date"), + } }; +} + +fn findColumn(sheet: *const Sheet, r: usize, name: []const u8) ?usize { + for (sheet.rows[r], 0..) |cell, c| { + // Footnote markers are digits glued onto the header text. + const header = std.mem.trimEnd(u8, trimmedText(cell), "0123456789"); + if (std.mem.eql(u8, header, name)) return c; } 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); +fn requireColumn(sheet: *const Sheet, r: usize, name: []const u8) Error!usize { + return findColumn(sheet, r, name) orelse { + if (!builtin.is_test) log.warn("header on row {d} has no '{s}' column", .{ r + 1, name }); + return error.UnexpectedHeader; + }; } -// ── 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 "Sample_IRA_1234.txt" matches -/// "Sample IRA *1234". -/// 3. **Single-WF-entry fallback.** If accounts.srf has exactly -/// one `institution::wells_fargo` entry, use it. Helpful for -/// users with one WF account; harmless when there are many -/// (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); - } - } - if (!builtin.is_test) { - var stderr_buf: [4096]u8 = undefined; - var sw = std.Io.File.stderr().writer(io, &stderr_buf); - try sw.interface.print( - "Error: --account '{s}' did not match any `institution::wells_fargo` entry in accounts.srf.\n", - .{name}, - ); - try printEntries(&sw.interface, account_map); - try sw.interface.flush(); - } - return error.UnknownAccount; - } - - // 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, e.account_number)) { - if (match != null) { - // More than one WF entry matched the - // filename - punt to the user. - break :blk null; - } - match = e; - } - } - break :blk match; +fn requireNumber(cell: Cell, r: usize, column: []const u8) Error!f64 { + return cell.asNumber() orelse { + if (!builtin.is_test) log.warn("row {d} has no number in '{s}'", .{ r + 1, column }); + return error.MissingValue; }; - 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; +/// `*1234` -> `1234`. +fn accountDigits(account: []const u8, r: usize) Error![]const u8 { + if (account.len > 1 and account[0] == '*') { + const digits = account[1..]; + for (digits) |c| { + if (!std.ascii.isDigit(c)) break; + } else return digits; } - if (wf_count == 1) return resolutionFor(io, single.?); + if (!builtin.is_test) log.warn("row {d}: account '{s}' is not '*' followed by digits", .{ r + 1, account }); + return error.UnexpectedAccountNumber; +} - // Couldn't pick. Print enumerated guidance. +/// A lot's trade date: `MM/DD/YYYY`, optionally suffixed `nc` +/// (noncovered). `Intra-Day` and `N/A` mean WF is not showing one. +fn parseTradeDate(cell: Cell, r: usize) Error!?Date { + const raw = trimmedText(cell); + if (std.mem.eql(u8, raw, "Intra-Day") or std.mem.eql(u8, raw, "N/A")) return null; + const date_text = if (std.mem.endsWith(u8, raw, "nc")) raw[0 .. raw.len - 2] else raw; + return Date.parseMdy(date_text) catch { + if (!builtin.is_test) log.warn("row {d}: unrecognized trade date '{s}'", .{ r + 1, raw }); + return error.UnexpectedTradeDate; + }; +} + +/// Check a per-account total against the lots summed under it. +fn closeTotal(open_total: *?OpenTotal) Error!void { + const t = open_total.* orelse return; + open_total.* = null; + if (@abs(t.lot_shares - t.shares) <= 1e-6 * @max(1.0, @abs(t.shares))) return; if (!builtin.is_test) { - var stderr_buf: [4096]u8 = undefined; - var sw = std.Io.File.stderr().writer(io, &stderr_buf); - if (wf_count == 0) { - try sw.interface.print( - "Error: no `institution::wells_fargo` entries found in accounts.srf.\n" ++ - " Add one (e.g. `account::Sample IRA *1234,tax_type::roth,institution::wells_fargo,account_number::1234`)\n" ++ - " and rerun the import.\n", - .{}, - ); - } else { - try sw.interface.print( - "Error: {d} Wells Fargo accounts in accounts.srf; cannot pick one automatically.\n" ++ - " Pass --account NAME to disambiguate. Candidates:\n", - .{wf_count}, - ); - try printEntries(&sw.interface, account_map); - } - try sw.interface.flush(); + log.warn("row {d}: {s} {s} totals {d} shares but its lots sum to {d}", .{ t.row + 1, t.account, t.symbol, t.shares, t.lot_shares }); + if (t.lot_shares == 0) log.warn("no lots follow it: zfin needs the 'Portfolio-Expanded Detail' download, not 'Collapsed'", .{}); } - return error.AmbiguousWellsFargoAccount; + return error.InconsistentExport; } -/// 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; - } +fn trimmedText(cell: Cell) []const u8 { + return std.mem.trim(u8, cell.asText() orelse "", " "); } -/// 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 { - if (!builtin.is_test) { - var stderr_buf: [512]u8 = undefined; - var sw = std.Io.File.stderr().writer(io, &stderr_buf); - try sw.interface.print( - "Error: WF account '{s}' has no `account_number::` field in accounts.srf.\n" ++ - " Add one (the trailing digits after `*` work well, e.g. `account_number::1234`).\n", - .{entry.account}, - ); - try sw.interface.flush(); - } - return error.UnknownAccount; - }; - return .{ .account_number = num, .account_name = entry.account }; -} - -/// 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. -/// -/// `account_number` (when non-null) is also tried as an -/// alternate anchor: a filename containing `accounts.srf`'s -/// `account_number::` value matches even when the account name -/// itself has no trailing digit run (e.g. user named the file -/// `1234.txt` and recorded `account::Sample Roth IRA, -/// account_number::1234` without putting `*1234` in the name). -/// This is the more user-friendly path; without it, the user -/// would have to keep the digit suffix in two places. -/// -/// Examples: -/// filenameMatchesAccount("Sample_IRA_1234", "Sample IRA *1234", null) -> true -/// filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null) -> true (digits match) -/// filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null) -> false -/// filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234") -> true (account_number anchor) -fn filenameMatchesAccount(filename: []const u8, account_name: []const u8, account_number: ?[]const u8) bool { - // Extract the trailing digit run from the account name. - // "Sample IRA *1234" -> "1234". - 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; - - // Try the `account_number::` field as an alternate anchor. - // Useful when the user didn't bother to put the digits in - // the human-readable account name. We only treat the - // account_number as an anchor when it's all digits (e.g. - // "1234"); alphanumeric account numbers like Schwab's - // "Z123" prefixed format wouldn't be a useful filename hint - // for a WF import anyway, but tolerating them here as a - // substring match is harmless. So: if the number is all - // digits, do an exact substring; if it's mixed, also try a - // substring - // (case-insensitive) which is the broader fuzzy fallback. - if (account_number) |num| { - if (num.len > 0 and std.mem.indexOf(u8, filename, num) != 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 ──────────────────────────────────────────────────── +// ---- 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("")); +fn txt(s: []const u8) Cell { + return .{ .text = s }; } -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); +fn num(v: f64) Cell { + return .{ .number = v }; } -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")); +/// Header with the real export's names, a footnote-suffixed one among +/// them, in a different order from the real file (columns are found +/// by name). +const header_row = [_]Cell{ txt("Description"), txt("Symbol"), txt("Account Number"), txt("Cash/Margin"), txt("Market Value"), txt("Shares"), txt("Tax Term"), txt("Total Cost1"), txt("Trade Date1") }; + +/// Placeholder household: accounts *1234 and *5678. +/// +/// - cash: *1234 balance + sweep + accrued (with a household subtotal +/// row), *5678 sweep only +/// - stocks: a two-lot AAPL in *1234 (total + lots) and a single-lot +/// MSFT in *5678 +/// - ETFs: VTI in both accounts (household total, then *1234's two-lot +/// total, then *5678's single lot), and a money-market fund +/// - mutual funds: an Intra-Day lot and a noncovered lot +const fixture_rows = [_][]const Cell{ + &.{txt("Priced as of Close on 10/02/2026")}, + &.{txt("Account Number: HOUSEHOLD")}, + &.{}, + &.{txt("Cash/Cash Alternatives & Margin")}, + &.{ txt("Description"), txt("Account Number"), txt("Market Value") }, + &.{ txt("Cash Balance"), txt("*1234"), num(10) }, + &.{ txt("Bank Deposit Sweep "), txt("*1234"), num(500) }, + &.{ txt("Bank Deposit Sweep "), txt("*5678"), num(250) }, + &.{ txt("Accrued Money Market & Sweep Interest"), txt("Detail"), num(0.75) }, + &.{ txt("Accrued Money Market & Sweep Interest"), txt("*1234"), num(0.5) }, + &.{ txt("Accrued Money Market & Sweep Interest"), txt("*5678"), num(0.25) }, + &.{ txt("Total Cash/Cash Alternatives & Margin"), .empty, num(761.0) }, + &.{}, + &.{txt("Stocks")}, + &header_row, + &.{txt("Common Stock")}, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(3000), num(15), txt("Detail"), num(2000), txt("Detail") }, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(2000), num(10), txt("LONG"), num(1200), txt("03/15/2021") }, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(1000), num(5), txt("SHORT"), num(800), txt("01/10/2026") }, + &.{ txt("MICROSOFT CORP"), txt("MSFT"), txt("*5678"), txt("Cash"), num(4000), num(10), txt("LONG"), num(3000), txt("06/01/2020") }, + &.{ txt("Total Stocks"), .empty, .empty, .empty, num(7000) }, + &.{}, + &.{txt("ETFs")}, + &header_row, + &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("Detail"), txt("Cash"), num(9000), num(30), txt("Detail"), num(7000), txt("Detail") }, + &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), txt("Cash"), num(6000), num(20), txt("Detail"), num(4500), txt("Detail") }, + &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), txt("Cash"), num(3600), num(12), txt("LONG"), num(2400), txt("02/03/2022") }, + &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), txt("Cash"), num(2400), num(8), txt("SHORT"), num(2100), txt("09/09/2026") }, + &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*5678"), txt("Cash"), num(3000), num(10), txt("LONG"), num(2500), txt("04/04/2023") }, + &.{ txt("SAMPLE MONEY MARKET"), txt("WMPXX"), txt("*5678"), txt("Cash"), num(100), num(100), txt("SHORT"), num(100), txt("05/05/2026") }, + &.{ txt("Total ETFs"), .empty, .empty, .empty, num(9100) }, + &.{}, + &.{txt("Mutual Funds")}, + &header_row, + &.{txt("Open End")}, + &.{ txt("SAMPLE GROWTH FUND"), txt("SMPLX"), txt("*1234"), txt("Network Fund"), num(5000), num(250), txt("N/A"), txt("N/A"), txt("Intra-Day") }, + &.{ txt("SAMPLE INCOME FUND"), txt("SMPIX"), txt("*5678"), txt("Special Product"), num(1100), num(100), txt("N/A"), num(1000), txt("07/07/2015nc") }, + &.{ txt("Total Mutual Funds"), .empty, .empty, .empty, num(6100) }, + &.{}, + &.{txt("Total Portfolio")}, + &.{ .empty, txt("Market Value") }, + &.{ txt("Total"), num(23961) }, + &.{txt("1 Securities with intra-day activity will not display information in the following data fields: Trade Date, ...")}, +}; + +const fixture: Sheet = .{ .name = sheet_name, .rows = &fixture_rows }; + +fn sheetWith(rows: []const []const Cell) Sheet { + return .{ .name = sheet_name, .rows = rows }; } -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: parses across intermediate totals (Stocks Total + ETFs Total)" { - const allocator = testing.allocator; - // The WF holdings page splits positions into multiple - // sections (Stocks, ETFs, Bonds, ...), each terminated by its - // own `
Total` footer. The parser must keep going - // past intermediate totals to capture records in subsequent - // sections. (Real-world example: a multi-section export with - // 43 stocks then 13 ETFs separated by `Stocks Total`.) - 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" ++ - "Stocks Total\n" ++ - "$127,655.40\n" ++ - "ETFs\n" ++ - "ETF table has been sorted ...\n" ++ - "\t\n" ++ - "DBP , popup\n" ++ // SHOULD be parsed (next section) - "INVESCO PRECIOUS METALS ETF\n" ++ - "\tMultiple(1) \t\n" ++ - "10\n" ++ - "@ $50.00\n" ++ - "\t\n" ++ - "$55.00\n" ++ - "+$0.10\n" ++ - "\t\n" ++ - "$550.00\n" ++ - "+$1.00 (+0.18%)\n" ++ - "\t\n" ++ - "+$50.00\n" ++ - "+10.00%\n" ++ - "\t\n" ++ - "$5.00\n" ++ - "\t\n" ++ - "ETFs Total\n" ++ - "$550.00\n"; - - const positions = try parsePaste(allocator, data); - defer allocator.free(positions); - try testing.expectEqual(@as(usize, 2), positions.len); - try testing.expectEqualStrings("GSLC", positions[0].symbol); - try testing.expectEqualStrings("DBP", positions[1].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); -} - -test "parsePaste: accepts both `SYMBOL,popup` and `SYMBOL , popup` anchors" { - // Wells Fargo emits two slightly different anchor shapes - // depending on what part of the holdings table the user - // copied - stocks tend to come out as `SYMBOL,popup` (no - // spaces) while ETFs come out as `SYMBOL , popup` (with - // spaces). Single-paste files routinely mix both forms, so - // the parser must accept either. - const allocator = testing.allocator; - const data = - "XOM,popup\n" ++ // no-space form (stock) - "EXXON MOBIL CORP\n" ++ - "\tMultiple(4) \t\n" ++ - "50\n" ++ - "@ $129.66\n" ++ - "\t\n" ++ - "$154.92\n" ++ - "-$0.37\n" ++ - "\t\n" ++ - "$7,746.00\n" ++ - "-$18.50 (-0.24%)\n" ++ - "\t\n" ++ - "+$1,262.85\n" ++ - "+19.48%\n" ++ - "\t\n" ++ - "$206.00\n" ++ - "\t\n" ++ - "GSLC , popup\n" ++ // with-space form (ETF) - "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, 2), positions.len); - try testing.expectEqualStrings("XOM", positions[0].symbol); - try testing.expectEqualStrings("GSLC", positions[1].symbol); -} - -test "parsePaste: trailing cash section emits a cash position" { - // After the positions table, WF pastes may include a - // "Cash, Cash Alternatives and Margin" section listing the - // account's cash balance. The parser captures that as a - // synthetic cash position; the downstream resolver stamps - // the account fields and `synthesizeLots` emits a - // `security_type::cash` lot. - const allocator = testing.allocator; - const data = - "XOM,popup\n" ++ - "EXXON MOBIL CORP\n" ++ - "\tMultiple(4) \t\n" ++ - "50\n" ++ - "@ $129.66\n" ++ - "\t\n" ++ - "$154.92\n" ++ - "-$0.37\n" ++ - "\t\n" ++ - "$7,746.00\n" ++ - "-$18.50 (-0.24%)\n" ++ - "\t\n" ++ - "+$1,262.85\n" ++ - "+19.48%\n" ++ - "\t\n" ++ - "$206.00\n" ++ - "\t\n" ++ - "ETFs Total\n" ++ - "$7,746.00\n" ++ - "Cash, Cash Alternatives and Margin\n" ++ - "Cash alternatives and margin table has been sorted ...\n" ++ - "\t\n" ++ - "Sample Roth IRA *1234\n" ++ - "\t$14,216.88\n" ++ - "\t\n" ++ - "Cash Total\n" ++ - "\t\n" ++ - "$14,216.88 \n"; - - const positions = try parsePaste(allocator, data); - defer allocator.free(positions); - try testing.expectEqual(@as(usize, 2), positions.len); - try testing.expectEqualStrings("XOM", positions[0].symbol); - try testing.expect(!positions[0].is_cash); - // Cash position - try testing.expectEqualStrings("", positions[1].symbol); - try testing.expect(positions[1].is_cash); - try testing.expectApproxEqAbs(@as(f64, 14216.88), positions[1].current_value.?, 0.01); - try testing.expect(positions[1].quantity == null); - try testing.expect(positions[1].cost_basis == null); -} - -test "parsePaste: cash section absent is a no-op" { - // When the user only pastes the positions table (no cash - // section), parsePaste returns just the positions. Regression - // for the simpler positions-only paste shape (no trailing - // cash table). - const allocator = testing.allocator; - 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"; - - 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); -} - -test "parsePaste: 529-plan layout (no trade-date column, N/A avg cost)" { - // Some WF paste shapes - typically 529 plans and managed - // mutual-fund accounts - omit the trade-date column entirely - // and report `N/A` where the avg-cost would be. The parser - // must handle both differences: - // - // 1. Description goes directly to shares (no Multiple(N) - // / MM/DD/YYYY line in between). - // 2. Avg-cost line is `N/A`, not `@ $price`. cost_basis - // becomes null; downstream synthesis falls back to - // market_value / shares. - const allocator = testing.allocator; - const data = - "JEFAX, popup\n" ++ - "EDUCATION TR ALASKA ^\n" ++ - "\t\n" ++ - "803.135\n" ++ // shares - no trade-date line precedes - "N/A\n" ++ // avg cost: not provided - "\t\n" ++ - "$30.22\n" ++ // last price - "+$0.01\n" ++ // day change - "\t\n" ++ - "$24,270.74\n" ++ // market value - "+$8.03 (+0.03%)\n" ++ - "\t\n" ++ - "N/A\n" ++ - "N/A\n" ++ - "\t\n" ++ - "N/A\n"; - - const positions = try parsePaste(allocator, data); - defer allocator.free(positions); - try testing.expectEqual(@as(usize, 1), positions.len); - try testing.expectEqualStrings("JEFAX", positions[0].symbol); - try testing.expectApproxEqAbs(@as(f64, 803.135), positions[0].quantity.?, 0.001); - try testing.expectApproxEqAbs(@as(f64, 24270.74), positions[0].current_value.?, 0.01); - try testing.expect(positions[0].cost_basis == null); - try testing.expect(!positions[0].is_cash); -} - -test "parsePaste: handles two 529-plan records back-to-back" { - // Verify the no-trade-date / N/A-cost path correctly - // resumes the outer scan past the previous record's - // market value, finding the next anchor. - const allocator = testing.allocator; - const data = - "JEFAX, popup\n" ++ - "FUND A\n" ++ - "\t\n" ++ - "803.135\n" ++ - "N/A\n" ++ - "\t\n" ++ - "$30.22\n" ++ - "+$0.01\n" ++ - "\t\n" ++ - "$24,270.74\n" ++ - "+$8.03 (+0.03%)\n" ++ - "\t\n" ++ - "N/A\n" ++ - "N/A\n" ++ - "\t\n" ++ - "N/A\n" ++ - "\t\n" ++ - "Not Rated\n" ++ - "\t\n" ++ - "JHPIX, popup\n" ++ - "FUND B\n" ++ - "\t\n" ++ - "131.109\n" ++ - "N/A\n" ++ - "\t\n" ++ - "$18.22\n" ++ - "+$0.01\n" ++ - "\t\n" ++ - "$2,388.81\n"; - - const positions = try parsePaste(allocator, data); - defer allocator.free(positions); - try testing.expectEqual(@as(usize, 2), positions.len); - try testing.expectEqualStrings("JEFAX", positions[0].symbol); - try testing.expectEqualStrings("JHPIX", positions[1].symbol); - try testing.expect(positions[0].cost_basis == null); - try testing.expect(positions[1].cost_basis == null); -} - -test "isPopupAnchor: accepts both compact and spaced forms" { - try testing.expect(isPopupAnchor("XOM,popup")); - try testing.expect(isPopupAnchor("XOM ,popup")); - try testing.expect(isPopupAnchor("XOM, popup")); - try testing.expect(isPopupAnchor("XOM , popup")); - try testing.expect(isPopupAnchor("BRK'B,popup")); - // Negative cases. - try testing.expect(!isPopupAnchor("XOM popup")); - try testing.expect(!isPopupAnchor("popup")); - try testing.expect(!isPopupAnchor("")); -} - -test "popupSymbol: extracts symbol from compact form" { - try testing.expectEqualStrings("XOM", popupSymbol("XOM,popup").?); - try testing.expectEqualStrings("BRK'B", popupSymbol("BRK'B,popup").?); - try testing.expectEqualStrings("XOM", popupSymbol("XOM, popup").?); - // Empty symbol part -> null. - try testing.expect(popupSymbol(",popup") == null); -} - -// ── 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("Sample_IRA_1234", "Sample IRA *1234", null)); - try testing.expect(filenameMatchesAccount("1234.txt", "Sample IRA *1234", null)); - try testing.expect(filenameMatchesAccount("smpl-ira-1234", "Sample IRA *1234", null)); - // Different digit suffix -> no match. - try testing.expect(!filenameMatchesAccount("Sample_IRA_5678", "Sample IRA *1234", null)); - try testing.expect(!filenameMatchesAccount("portfolio_other", "Sample IRA *1234", null)); -} - -test "filenameMatchesAccount: account_number anchor when name lacks digits" { - // User stored the digits in `account_number::` but didn't - // bother to put them in the human-readable account name. - // The number itself can anchor the filename match. - try testing.expect(filenameMatchesAccount("1234.txt", "Sample Roth IRA", "1234")); - try testing.expect(filenameMatchesAccount("smpl_1234", "Sample Roth IRA", "1234")); - // Wrong digits -> no match. - try testing.expect(!filenameMatchesAccount("9999.txt", "Sample Roth IRA", "1234")); - // No account_number and no digits in name -> no match - // (alphaRunsContained doesn't help against a digit-only file). - try testing.expect(!filenameMatchesAccount("1234.txt", "Sample Roth IRA", null)); -} - -test "filenameMatchesAccount: name digits take precedence over account_number" { - // Both signals available; either one matching is enough. - // (Tests the OR semantics - name digits win first because - // they're checked first; we also verify account_number-only - // matches when name digits don't appear.) - try testing.expect(filenameMatchesAccount("Sample_1234", "Sample *1234", "9999")); - try testing.expect(filenameMatchesAccount("Sample_9999", "Sample *1234", "9999")); - try testing.expect(!filenameMatchesAccount("Sample_5555", "Sample *1234", "9999")); -} - -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", null)); - // 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", null)); - // Partial overlap also doesn't match - every run must be - // present. - try testing.expect(!filenameMatchesAccount("emils_only", "Emils Brokerage", null)); -} - -test "filenameMatchesAccount: case-insensitive fallback" { - try testing.expect(filenameMatchesAccount("EMILS_brokerage", "Emils Brokerage", null)); - try testing.expect(filenameMatchesAccount("emils_BROKERAGE", "Emils Brokerage", null)); -} - -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 = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, +/// accounts.srf entries for the fixture's two accounts. +fn fixtureAccounts() [2]analysis.AccountTaxEntry { + return .{ + .{ .account = "Sample IRA *1234", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "1234" }, .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" }, - }); - defer account_map.deinit(); - - const r = try resolveAccount(testing.io, account_map, "anything.txt", "Sample IRA *1234"); - try testing.expectEqualStrings("1234", r.account_number); - try testing.expectEqualStrings("Sample IRA *1234", r.account_name); -} - -test "resolveAccount: explicit override that doesn't match -> UnknownAccount" { - const allocator = testing.allocator; - var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, - }); - 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 = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, - .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" }, - }); - defer account_map.deinit(); - - const r = try resolveAccount(testing.io, account_map, "/path/to/Sample_IRA_1234.txt", null); - try testing.expectEqualStrings("1234", r.account_number); -} - -test "resolveAccount: single-WF-entry fallback when filename has no signal" { - const allocator = testing.allocator; - var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, - // Non-WF entry shouldn't interfere. - .{ .account = "Sample Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, - }); - defer account_map.deinit(); - - const r = try resolveAccount(testing.io, account_map, "unrelated_filename.txt", null); - try testing.expectEqualStrings("1234", r.account_number); -} - -test "resolveAccount: ambiguous when 2+ WF entries and no signal" { - const allocator = testing.allocator; - var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, - .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" }, - }); - defer account_map.deinit(); - - 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 = "Sample 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 = "Sample IRA", .tax_type = .roth, .institution = "wells_fargo", .account_number = null }, - }); - defer account_map.deinit(); - - try testing.expectError(error.UnknownAccount, resolveAccount(testing.io, account_map, "Sample_IRA.txt", null)); -} - -test "applyAccountToPositions: patches every position's account fields" { - const allocator = testing.allocator; - var account_map = try testAccountMap(allocator, &.{ - .{ .account = "Sample IRA *1234", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" }, - }); - 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, "Sample_IRA_1234.txt", null, &positions); - try testing.expectEqualStrings("1234", positions[0].account_number); - try testing.expectEqualStrings("Sample IRA *1234", positions[0].account_name); - try testing.expectEqualStrings("1234", positions[1].account_number); - try testing.expectEqualStrings("Sample IRA *1234", positions[1].account_name); +} + +fn freeLotsForTest(allocator: std.mem.Allocator, lots: []const Lot) void { + for (lots) |lot| { + allocator.free(lot.symbol); + allocator.free(lot.account.?); + } + allocator.free(lots); +} + +test "parsePositions: every lot and cash row, nothing else" { + const positions = try parsePositions(testing.allocator, &fixture); + defer testing.allocator.free(positions); + + // 5 cash rows (subtotal skipped), 3 stock lots, 4 ETF lots (both + // totals skipped), 2 fund lots. + try testing.expectEqual(@as(usize, 14), positions.len); + + const cash = positions[0]; + try testing.expectEqualStrings("1234", cash.account_number); + try testing.expectEqualStrings("*1234", cash.account_name); + try testing.expectEqualStrings("", cash.symbol); + try testing.expectEqualStrings("Cash Balance", cash.description); + try testing.expect(cash.is_cash); + try testing.expect(cash.quantity == null); + try testing.expectEqual(@as(f64, 10), cash.current_value.?); + // Trailing padding is trimmed. + try testing.expectEqualStrings("Bank Deposit Sweep", positions[1].description); + + const lot = positions[5]; + try testing.expectEqualStrings("AAPL", lot.symbol); + try testing.expectEqualStrings("APPLE INC", lot.description); + try testing.expectEqual(@as(f64, 10), lot.quantity.?); + try testing.expectEqual(@as(f64, 2000), lot.current_value.?); + try testing.expectEqual(@as(f64, 1200), lot.cost_basis.?); + try testing.expect(!lot.is_cash); + + // Per-symbol sums equal what WF's own totals say. + var vti: f64 = 0; + for (positions) |p| { + if (std.mem.eql(u8, p.symbol, "VTI")) vti += p.quantity.?; + } + try testing.expectEqual(@as(f64, 30), vti); + + // A money-market holding is cash, valued in dollars. + const mm = positions[11]; + try testing.expectEqualStrings("WMPXX", mm.symbol); + try testing.expect(mm.is_cash); + try testing.expect(mm.quantity == null); + try testing.expect(mm.cost_basis == null); + + // A fund with no reported cost. + try testing.expectEqualStrings("SMPLX", positions[12].symbol); + try testing.expect(positions[12].cost_basis == null); +} + +test "parseLots: real trade dates and costs; one cash lot per account" { + const allocator = testing.allocator; + var entries = fixtureAccounts(); + const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = allocator }; + + const lots = try parseLots(allocator, &fixture, account_map); + defer freeLotsForTest(allocator, lots); + + // 2 cash lots (one per account) + 3 + 4 + 2. + try testing.expectEqual(@as(usize, 11), lots.len); + + // Cash: balance + sweep + accrued for *1234; the household + // subtotal row is not double counted. + try testing.expectEqual(LotType.cash, lots[0].security_type); + try testing.expectEqualStrings("Sample IRA *1234", lots[0].account.?); + try testing.expectApproxEqAbs(@as(f64, 510.5), lots[0].shares, 1e-9); + try testing.expectEqual(@as(f64, 1.0), lots[0].open_price); + try testing.expectEqualStrings("Sample Brokerage *5678", lots[1].account.?); + try testing.expectApproxEqAbs(@as(f64, 250.25), lots[1].shares, 1e-9); + + // A dated lot: real trade date, cost per share. + const aapl = lots[2]; + try testing.expectEqualStrings("AAPL", aapl.symbol); + try testing.expectEqual(@as(f64, 10), aapl.shares); + try testing.expect(Date.fromYmd(2021, 3, 15).eql(aapl.open_date)); + try testing.expectEqual(@as(f64, 120), aapl.open_price); + try testing.expect(aapl.note == null); + + // Money market: a cash lot keeping its symbol. + const mm = lots[8]; + try testing.expectEqualStrings("WMPXX", mm.symbol); + try testing.expectEqual(LotType.cash, mm.security_type); + try testing.expectEqual(@as(f64, 100), mm.shares); + + // Intra-Day fund: unknown date, priced from market value. + const intraday = lots[9]; + try testing.expectEqualStrings("SMPLX", intraday.symbol); + try testing.expect(Date.epoch.eql(intraday.open_date)); + try testing.expectEqual(@as(f64, 20), intraday.open_price); // 5000 / 250 + + // Noncovered lot: the `nc` suffix does not hide the date. + try testing.expect(Date.fromYmd(2015, 7, 7).eql(lots[10].open_date)); + try testing.expectEqual(@as(f64, 10), lots[10].open_price); +} + +test "parseLots: an account missing from accounts.srf is UnmappedAccount" { + const account_map: analysis.AccountMap = .{ .entries = &.{}, .allocator = testing.allocator }; + try testing.expectError(error.UnmappedAccount, parseLots(testing.allocator, &fixture, account_map)); +} + +test "parseLots: every allocation failure is clean" { + const S = struct { + fn run(allocator: std.mem.Allocator, map: analysis.AccountMap) !void { + freeLotsForTest(allocator, try parseLots(allocator, &fixture, map)); + } + }; + var entries = fixtureAccounts(); + const map: analysis.AccountMap = .{ .entries = &entries, .allocator = testing.allocator }; + try testing.checkAllAllocationFailures(testing.allocator, S.run, .{map}); +} + +test "positionsSheet: finds the sheet by name" { + var wb: biff8.Workbook = .{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .sheets = &.{fixture} }; + defer wb.deinit(); + try testing.expectEqualStrings(sheet_name, (try positionsSheet(&wb)).name); + + var other: biff8.Workbook = .{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .sheets = &.{.{ .name = "Sheet1", .rows = &.{} }} }; + defer other.deinit(); + try testing.expectError(error.NotPositionsExport, positionsSheet(&other)); +} + +test "a header missing a needed column is UnexpectedHeader" { + const no_trade_date = sheetWith(&.{ + &.{ txt("Description"), txt("Symbol"), txt("Account Number"), txt("Market Value"), txt("Shares"), txt("Total Cost") }, + }); + try testing.expectError(error.UnexpectedHeader, parsePositions(testing.allocator, &no_trade_date)); + + const cash_no_value = sheetWith(&.{&.{ txt("Description"), txt("Account Number") }}); + try testing.expectError(error.UnexpectedHeader, parsePositions(testing.allocator, &cash_no_value)); +} + +test "malformed rows are reported, not skipped" { + const bad_account = sheetWith(&.{ + &header_row, + &.{ txt("APPLE INC"), txt("AAPL"), txt("1234"), txt("Cash"), num(10), num(1), txt("LONG"), num(5), txt("03/15/2021") }, + }); + try testing.expectError(error.UnexpectedAccountNumber, parsePositions(testing.allocator, &bad_account)); + + const bad_cash_account = sheetWith(&.{ + &.{ txt("Description"), txt("Account Number"), txt("Market Value") }, + &.{ txt("Cash Balance"), txt("*12a4"), num(10) }, + }); + try testing.expectError(error.UnexpectedAccountNumber, parsePositions(testing.allocator, &bad_cash_account)); + + const no_shares = sheetWith(&.{ + &header_row, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(10), txt("N/A"), txt("LONG"), num(5), txt("03/15/2021") }, + }); + try testing.expectError(error.MissingValue, parsePositions(testing.allocator, &no_shares)); + + const no_value = sheetWith(&.{ + &.{ txt("Description"), txt("Account Number"), txt("Market Value") }, + &.{ txt("Cash Balance"), txt("*1234"), txt("N/A") }, + }); + try testing.expectError(error.MissingValue, parsePositions(testing.allocator, &no_value)); +} + +test "an unknown trade date fails parseLots but not parsePositions" { + // "Multiple" is the kind of thing a different download type might + // print in place of per-lot dates. + const sheet = sheetWith(&.{ + &header_row, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(10), num(1), txt("LONG"), num(5), txt("Multiple") }, + }); + const positions = try parsePositions(testing.allocator, &sheet); + testing.allocator.free(positions); + + var entries = fixtureAccounts(); + const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = testing.allocator }; + try testing.expectError(error.UnexpectedTradeDate, parseLots(testing.allocator, &sheet, account_map)); +} + +test "an account total that disagrees with its lots is InconsistentExport" { + const short = sheetWith(&.{ + &header_row, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(30), num(15), txt("Detail"), num(20), txt("Detail") }, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(20), num(10), txt("LONG"), num(12), txt("03/15/2021") }, + &.{ txt("MICROSOFT CORP"), txt("MSFT"), txt("*1234"), txt("Cash"), num(40), num(1), txt("LONG"), num(30), txt("06/01/2020") }, + }); + try testing.expectError(error.InconsistentExport, parsePositions(testing.allocator, &short)); + + // Totals with no lots under them: a Collapsed Detail download. + const collapsed = sheetWith(&.{ + &header_row, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(30), num(15), txt("Detail"), num(20), txt("Detail") }, + &.{ txt("Total Stocks"), .empty, .empty, .empty, num(30) }, + }); + try testing.expectError(error.InconsistentExport, parsePositions(testing.allocator, &collapsed)); + + // An open total is still checked when the sheet just ends. + const unterminated = sheetWith(&.{ + &header_row, + &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(30), num(15), txt("Detail"), num(20), txt("Detail") }, + }); + try testing.expectError(error.InconsistentExport, parsePositions(testing.allocator, &unterminated)); +} + +test "rows outside any section are ignored" { + const sheet = sheetWith(&.{ + &.{ txt("Stray"), txt("AAPL"), txt("*1234"), num(1) }, + &.{txt("Total Portfolio")}, + }); + const positions = try parsePositions(testing.allocator, &sheet); + defer testing.allocator.free(positions); + try testing.expectEqual(@as(usize, 0), positions.len); +} + +test "exportHint: names the right download for each failure" { + try testing.expect(std.mem.indexOf(u8, exportHint(error.NotCompoundFile), "Portfolio-Expanded Detail") != null); + try testing.expect(std.mem.indexOf(u8, exportHint(error.NotPositionsExport), "Positions") != null); + try testing.expect(std.mem.indexOf(u8, exportHint(error.InconsistentExport), "Collapsed") != null); + try testing.expect(std.mem.indexOf(u8, exportHint(error.Truncated), "again") != null); + try testing.expectEqualStrings("", exportHint(error.MissingValue)); } diff --git a/src/commands/audit.zig b/src/commands/audit.zig index c705b00..3ec0aa9 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -4,9 +4,12 @@ //! per-responsibility modules in the `audit/` directory: //! //! - `audit/hygiene.zig` - flagless portfolio hygiene check (no flags) -//! - `audit/fidelity.zig` - `--fidelity` positions-CSV reconciler +//! - `analytics/reconcile/fidelity.zig` - `--fidelity` positions-CSV +//! reconciler //! - `audit/schwab.zig` - `--schwab` positions-CSV + `--schwab-summary` //! reconcilers +//! - `analytics/reconcile/wells_fargo.zig` - `--wells-fargo` +//! positions-spreadsheet reconciler //! - `audit/common.zig` - shared comparison types + per-account display //! //! This file sits beside its `audit/` directory (the `tui.zig` + @@ -23,6 +26,8 @@ const framework = @import("framework.zig"); const common = @import("audit/common.zig"); const fidelity = @import("../analytics/reconcile/fidelity.zig"); +const wells_fargo = @import("../analytics/reconcile/wells_fargo.zig"); +const wf_parser = @import("../brokerage/wells_fargo.zig"); const schwab = @import("audit/schwab.zig"); const hygiene = @import("audit/hygiene.zig"); @@ -32,6 +37,7 @@ pub const ParsedArgs = struct { fidelity_csv: ?[]const u8 = null, schwab_csv: ?[]const u8 = null, schwab_summary: bool = false, + wells_fargo_xls: ?[]const u8 = null, verbose: bool = false, stale_days: u32 = hygiene.default_stale_days, }; @@ -60,10 +66,13 @@ pub const meta: framework.Meta = .{ \\ --schwab Schwab per-account positions CSV export \\ --schwab-summary Schwab account summary; copy from accounts \\ summary page, paste to stdin, then ^D + \\ --wells-fargo Wells Fargo positions spreadsheet + \\ (Download Type "Portfolio-Expanded Detail", + \\ Portfolio View "Positions") \\ , .uppercase_first_arg = false, - .user_errors = error{ UnexpectedArg, EmptyFile, NoAccountsFound, UnexpectedHeader, MissingFlagValue }, + .user_errors = error{ UnexpectedArg, EmptyFile, NoAccountsFound, UnexpectedHeader, MissingFlagValue, InvalidExport }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { @@ -77,6 +86,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr parsed.schwab_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); } else if (std.mem.eql(u8, a, "--schwab-summary")) { parsed.schwab_summary = true; + } else if (std.mem.eql(u8, a, "--wells-fargo")) { + parsed.wells_fargo_xls = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); } else if (std.mem.eql(u8, a, "--verbose")) { parsed.verbose = true; } else if (std.mem.eql(u8, a, "--stale-days")) { @@ -115,6 +126,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const fidelity_csv = parsed.fidelity_csv; const schwab_csv = parsed.schwab_csv; const schwab_summary = parsed.schwab_summary; + const wells_fargo_xls = parsed.wells_fargo_xls; const verbose = parsed.verbose; const stale_days = parsed.stale_days; @@ -122,7 +134,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // semantics (git blame, commit SHAs, etc.), so resolve the anchor - // but the large-lot check inside diffs portfolio content and needs // the merged view, so hand it the whole glob too. - if (fidelity_csv == null and schwab_csv == null and !schwab_summary) { + if (fidelity_csv == null and schwab_csv == null and !schwab_summary and wells_fargo_xls == null) { const pf = ctx.resolvePortfolioPath(); defer pf.deinit(allocator); var all = ctx.resolvePortfolioPaths() catch null; @@ -131,7 +143,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { return hygiene.runHygieneCheck(io, allocator, ctx.environ_map, svc, pf.path, paths, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out); } - // Reconciliation modes (--fidelity / --schwab / --schwab-summary): + // Reconciliation modes (--fidelity / --schwab / --schwab-summary / + // --wells-fargo): // load the union of all portfolio files so the comparison sees // every lot the user holds, even if they're split across multiple // portfolio_*.srf files. @@ -284,6 +297,38 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer allocator.free(absent); try common.displayAbsentAccounts(absent, color, "this export", out); } + + // Wells Fargo positions spreadsheet (one file, every WF account) + if (wells_fargo_xls) |xls_path| { + const xls_data = std.Io.Dir.cwd().readFileAlloc(io, xls_path, allocator, .limited(10 * 1024 * 1024)) catch |err| { + var msg_buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read Wells Fargo export {s}: {s}\n", .{ xls_path, @errorName(err) }) catch "Error: Cannot read Wells Fargo export\n"; + cli.stderrPrint(io, msg); + return error.InvalidExport; + }; + defer allocator.free(xls_data); + + var rec = wells_fargo.reconcileXls(allocator, portfolio, xls_data, account_map, prices, as_of) catch |err| switch (err) { + error.OutOfMemory => return err, + else => |e| { + var msg_buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read Wells Fargo export {s}: {s}\n", .{ xls_path, @errorName(e) }) catch "Error: Cannot read Wells Fargo export\n"; + cli.stderrPrint(io, msg); + cli.stderrPrint(io, wf_parser.exportHint(e)); + return error.InvalidExport; + }, + }; + defer rec.deinit(allocator); + + try common.displayResults(rec.results, color, out); + try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out); + + const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results); + defer allocator.free(present); + const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, wf_parser.institution, present, prices, as_of); + defer allocator.free(absent); + try common.displayAbsentAccounts(absent, color, "this export", out); + } } // ── Tests ──────────────────────────────────────────────────── @@ -296,6 +341,7 @@ test "parseArgs: defaults" { try std.testing.expect(parsed.fidelity_csv == null); try std.testing.expect(parsed.schwab_csv == null); try std.testing.expect(!parsed.schwab_summary); + try std.testing.expect(parsed.wells_fargo_xls == null); try std.testing.expect(!parsed.verbose); try std.testing.expectEqual(hygiene.default_stale_days, parsed.stale_days); } @@ -316,6 +362,21 @@ test "parseArgs: --schwab captures CSV path" { try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?); } +test "parseArgs: --wells-fargo captures the spreadsheet path" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "--wells-fargo", "wf.xls" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("wf.xls", parsed.wells_fargo_xls.?); +} + +test "parseArgs: --wells-fargo without a value is rejected" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--wells-fargo"}; + try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); +} + test "parseArgs: --schwab-summary boolean" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index f949616..3177611 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -31,6 +31,8 @@ const test_git = @import("../../testutil/git.zig"); const common = @import("common.zig"); const fidelity = @import("../../analytics/reconcile/fidelity.zig"); +const wells_fargo = @import("../../analytics/reconcile/wells_fargo.zig"); +const wf_parser = @import("../../brokerage/wells_fargo.zig"); const schwab = @import("schwab.zig"); const discover = zfin.brokerage.discover; const fmt = cli.fmt; @@ -1087,6 +1089,7 @@ pub fn runHygieneCheck( .fidelity_csv => "fidelity", .schwab_csv => "schwab csv", .schwab_summary => "schwab summary", + .wells_fargo_xls => "wells fargo", }; try out.print(" {s:<52} {s}\n", .{ f.path, kind_label }); } @@ -1131,11 +1134,14 @@ pub fn runHygieneCheck( // `file_data`, which is freed per loop iteration. var fidelity_present: std.ArrayList([]const u8) = .empty; var schwab_present: std.ArrayList([]const u8) = .empty; + var wells_fargo_present: std.ArrayList([]const u8) = .empty; defer { for (fidelity_present.items) |s| allocator.free(s); fidelity_present.deinit(allocator); for (schwab_present.items) |s| allocator.free(s); schwab_present.deinit(allocator); + for (wells_fargo_present.items) |s| allocator.free(s); + wells_fargo_present.deinit(allocator); } for (all_files.items) |f| { @@ -1211,6 +1217,36 @@ pub fn runHygieneCheck( try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results); }, + .wells_fargo_xls => { + var rec = wells_fargo.reconcileXls(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| switch (err) { + error.OutOfMemory => return err, + else => |e| { + try cli.printFg(out, color, cli.CLR_WARNING, " {s}: detected as wells fargo export but could not parse ({s}); skipped\n", .{ f.path, @errorName(e) }); + continue; + }, + }; + defer rec.deinit(allocator); + + // One WF download covers a whole household. If none of + // its accounts is in this portfolio's accounts.srf, it is + // someone else's export sitting in a shared directory; + // listing every account as a discrepancy would be noise. + if (!rec.mapsAnyAccount()) { + try cli.printFg(out, color, cli.CLR_MUTED, " wells fargo: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{rec.results.len}); + continue; + } + + if (verbose or common.hasAccountDiscrepancies(rec.results)) { + try out.print("\n", .{}); + try common.displayResults(rec.results, color, out); + try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out); + } else { + try cli.printFg(out, color, cli.CLR_POSITIVE, " wells fargo: {d} accounts, no discrepancies\n", .{rec.results.len}); + try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out); + } + + try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results); + }, } } @@ -1229,6 +1265,11 @@ pub fn runHygieneCheck( defer allocator.free(absent); try common.displayAbsentAccounts(absent, color, "any export", out); } + if (wells_fargo_present.items.len > 0) { + const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, wf_parser.institution, wells_fargo_present.items, prices, as_of); + defer allocator.free(absent); + try common.displayAbsentAccounts(absent, color, "any export", out); + } } // ── Section 6: Large new lots - confirm source ── diff --git a/src/commands/import.zig b/src/commands/import.zig index 0abcc9b..5b201d5 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -34,6 +34,17 @@ //! carries these. //! - `security_type::cash` for cash-classified positions //! +//! ## Wells Fargo: real lots +//! +//! Wells Fargo's "Portfolio-Expanded Detail" download lists every tax +//! lot with its trade date and cost, for every account in the +//! household, so a WF import writes one lot per WF lot instead of one +//! synthetic lot per position (`wells_fargo.parseLots`). Each lot's +//! `open_date` and `open_price` come from the export; only lots WF +//! shows without a date (`Intra-Day` funds) take the sentinel and fall +//! back to the prior lot as below. Hand-edited fields and the note are +//! inherited the same way for every source. +//! //! ## Re-import merge //! //! When the target portfolio file already exists, `import` reads @@ -46,7 +57,9 @@ //! hand-edited field (`Lot.hand_edited_fields`: `ticker`, //! `label`, `price`, `price_ratio`, `drip`, ...) from the prior //! lot; only `shares` and `security_type` come from the new -//! export. A re-import of an unchanged held position produces +//! export. (A lot whose export row carries a real buy date - +//! Wells Fargo - keeps its own `open_date` and `open_price`.) +//! A re-import of an unchanged held position produces //! byte-identical output, so `git diff` only surfaces actual //! brokerage changes (lot-size drift, real cost-basis //! adjustments). @@ -120,11 +133,13 @@ const std = @import("std"); const builtin = @import("builtin"); +const biff8 = @import("biff8"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); const Date = @import("../Date.zig"); -const portfolio_mod = @import("../models/portfolio.zig"); +const Lot = @import("../models/portfolio.zig").Lot; +const LotType = @import("../models/portfolio.zig").LotType; const cache = @import("../cache/store.zig"); const atomic = @import("../atomic.zig"); const fidelity = @import("../brokerage/fidelity.zig"); @@ -135,37 +150,24 @@ const analysis = @import("../analytics/analysis.zig"); const BrokeragePosition = brokerage_types.BrokeragePosition; -/// 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. +/// Source brokerage for the import, carrying the export's path. +/// Every source's export carries account numbers per row. 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, - }; + wells_fargo: []const u8, pub fn label(self: Source) []const u8 { return switch (self) { .fidelity => "fidelity", .schwab => "schwab", - .wells_fargo => "wells_fargo", + .wells_fargo => wells_fargo.institution, }; } pub fn path(self: Source) []const u8 { return switch (self) { - .fidelity => |p| p, - .schwab => |p| p, - .wells_fargo => |a| a.path, + inline else => |p| p, }; } }; @@ -181,11 +183,13 @@ 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 | --wells-fargo FILE [--account NAME]) [-y] + \\Usage: zfin -p import (--fidelity FILE | --schwab FILE | --wells-fargo FILE) [-y] \\ \\Synthesize a portfolio file from a brokerage positions export. - \\Each run REPLACES the target portfolio file with synthetic lots - \\drawn from the export - one lot per (account, symbol). + \\Each run REPLACES the target portfolio file with lots drawn + \\from the export: one synthetic lot per (account, symbol) for + \\Fidelity and Schwab, one lot per tax lot for Wells Fargo + \\(whose export carries each lot's trade date and cost). \\ \\Designed for managed accounts (direct-indexing baskets, accounts \\you don't track at lot granularity). Per-buy history is lost; @@ -200,10 +204,9 @@ pub const meta: framework.Meta = .{ \\`git diff` only flags genuine brokerage changes. Newly-introduced \\positions get `open_date::1970-01-01` (a "we don't know" \\sentinel; the next import will treat it as the prior anchor). + \\Wells Fargo lots keep the export's own date and cost. \\Lots that disappear from the export are silently dropped - if \\you sold a position between imports, it just stops appearing. - \\Only `shares` and `security_type` come from the export; every - \\hand-edited field on a prior lot is preserved. \\ \\Required: \\ -p, --portfolio Target portfolio file (must be a single @@ -211,19 +214,14 @@ pub const meta: framework.Meta = .{ \\ --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 + \\ --wells-fargo Wells Fargo positions spreadsheet + \\ (Download Type "Portfolio-Expanded + \\ Detail", Portfolio View "Positions", + \\ all brokerage accounts). Covers every + \\ WF account in one file. Pass `-` to \\ read from stdin. \\ \\Options: - \\ --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. \\ @@ -246,8 +244,7 @@ pub const meta: framework.Meta = .{ CannotReadCsv, CannotReadAccountsFile, UnmappedAccount, - AmbiguousWellsFargoAccount, - UnknownAccount, + InvalidExport, UserDeclined, WriteFailed, }, @@ -257,7 +254,6 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr 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; @@ -269,8 +265,6 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr schwab_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); } else if (std.mem.eql(u8, a, "--wells-fargo")) { wells_fargo_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); - } else if (std.mem.eql(u8, a, "--account")) { - account_override = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); } else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) { yes = true; } else { @@ -292,20 +286,12 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr 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) { - 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 } } + .{ .wells_fargo = p } else { cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n"); return error.MissingSource; @@ -331,21 +317,32 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // ── 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 = try readSourceData(io, allocator, csv_path); - defer allocator.free(csv_data); + // `-` selects stdin; any other path reads from disk. Same + // convention regardless of source. + const source_path = parsed.source.path(); + const source_data = try readSourceData(io, allocator, source_path); + defer allocator.free(source_data); // ── Parse ───────────────────────────────────────────────── + // + // The Wells Fargo export is a binary workbook; its positions + // borrow strings from the decoded sheet, so the workbook lives + // for the rest of the run. + var workbook: ?biff8.Workbook = null; + defer if (workbook) |*wb| wb.deinit(); + var wf_sheet: ?*const biff8.Sheet = null; + const positions: []BrokeragePosition = switch (parsed.source) { - .fidelity => try fidelity.parseCsv(allocator, csv_data), + .fidelity => try fidelity.parseCsv(allocator, source_data), .schwab => blk: { - const r = try schwab.parseCsv(allocator, csv_data); + const r = try schwab.parseCsv(allocator, source_data); break :blk r.positions; }, - .wells_fargo => try wells_fargo.parsePaste(allocator, csv_data), + .wells_fargo => blk: { + workbook = biff8.Workbook.parse(allocator, source_data) catch |err| return badWellsFargoExport(io, source_path, err); + wf_sheet = wells_fargo.positionsSheet(&workbook.?) catch |err| return badWellsFargoExport(io, source_path, err); + break :blk wells_fargo.parsePositions(allocator, wf_sheet.?) catch |err| return badWellsFargoExport(io, source_path, err); + }, }; defer allocator.free(positions); @@ -369,21 +366,6 @@ 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); - } - // ── Read the existing target file (if any) for merge ────── // // When the target file already exists, we treat its lots as @@ -419,13 +401,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { } // ── Synthesize lots ─────────────────────────────────────── - const lots = synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today, prior_lookup_opt) catch |err| switch (err) { - error.UnmappedAccount => { - // synthesizeLots already printed the offending account - // numbers to stderr; just propagate as a user-level error. - return err; - }, - else => |e| return e, + // + // `UnmappedAccount` arrives with the offending account numbers + // already printed to stderr. + const lots = switch (parsed.source) { + .wells_fargo => try wellsFargoLots(io, allocator, wf_sheet.?, source_path, positions, account_map, ctx.today, prior_lookup_opt), + else => try synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today, prior_lookup_opt), }; defer freeLots(allocator, lots); @@ -536,14 +517,27 @@ fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![ }; return data; } - return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch { + return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch |err| { 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"; + const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read source file {s}: {s}\n", .{ path, @errorName(err) }) catch "Error: Cannot read source file\n"; cli.stderrPrint(io, msg); return error.CannotReadCsv; }; } +/// Explain a Wells Fargo export that could not be read, then return +/// the user-level error. +fn badWellsFargoExport(io: std.Io, path: []const u8, err: wells_fargo.ExportError) error{ InvalidExport, OutOfMemory } { + if (err == error.OutOfMemory) return error.OutOfMemory; + // Quiet under tests, like `requireMapped`; the error is what they check. + if (builtin.is_test) return error.InvalidExport; + var msg_buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read Wells Fargo export {s}: {s}\n", .{ path, @errorName(err) }) catch "Error: Cannot read Wells Fargo export\n"; + cli.stderrPrint(io, msg); + cli.stderrPrint(io, wells_fargo.exportHint(err)); + return error.InvalidExport; +} + /// 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". @@ -582,11 +576,11 @@ fn confirmOverwrite(io: std.Io, path: []const u8) !bool { /// `Portfolio` and remain valid as long as the source portfolio /// does. const PriorLotsLookup = struct { - map: std.StringHashMap(*const portfolio_mod.Lot), + map: std.StringHashMap(*const Lot), allocator: std.mem.Allocator, - fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot, today: Date) !PriorLotsLookup { - var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator); + fn init(allocator: std.mem.Allocator, lots: []const Lot, today: Date) !PriorLotsLookup { + var map = std.StringHashMap(*const Lot).init(allocator); errdefer { var it = map.keyIterator(); while (it.next()) |k| allocator.free(k.*); @@ -633,7 +627,7 @@ const PriorLotsLookup = struct { /// Find the prior `Lot` for `(symbol, account)`, returning /// null when no match exists. Caller must keep the source /// portfolio alive while using the returned pointer. - fn find(self: PriorLotsLookup, symbol: []const u8, account: []const u8) !?*const portfolio_mod.Lot { + fn find(self: PriorLotsLookup, symbol: []const u8, account: []const u8) !?*const Lot { var key_buf: [256]u8 = undefined; // Fast path: stack-allocate the key. Fall back to heap // for unusually long symbols/accounts. @@ -654,28 +648,15 @@ const PriorLotsLookup = struct { } }; -/// Synthesize a `Lot` per `BrokeragePosition`. Resolves each -/// brokerage account_number to a portfolio account name via -/// `account_map`; refuses (with a stderr listing of unmapped -/// numbers) if any can't be resolved. +/// Synthesize a `Lot` per `BrokeragePosition` (Fidelity, Schwab). +/// Resolves each brokerage account_number to a portfolio account +/// name via `account_map`; refuses (with a stderr listing of +/// unmapped numbers) if any can't be resolved. /// -/// `today` stamps the `note::` field on lots that don't have a -/// matching prior entry (`note::imported YYYY-MM-DD`). -/// Lots that DO match a prior entry inherit that prior lot's -/// note (which carries the original first-seen date), so a -/// re-import of an unchanged held position produces a -/// byte-identical line - the `git diff` only shows genuine -/// brokerage changes. -/// -/// `prior_lookup` (if non-null) carries the lots from the -/// existing target file. For each synthesized lot, we look up -/// `(symbol, account)`: -/// - **Match:** preserve `open_date`, `open_price`, and `note` -/// from the prior lot. Brokerage shares/cost-basis still come -/// from the new export. -/// - **No match:** new position. Use `Date.epoch` for -/// `open_date` (no signal to do better) and stamp today's -/// date in the note. +/// Each position becomes a lot by `brokerage_types.lotFromPosition` +/// (cost-basis open price, cash at $1, `Date.epoch` open date), then +/// takes what the export cannot know from the prior file via +/// `inheritFromPrior`. /// /// Takes `io` so it can print the unmapped-account-number /// enumeration directly to stderr - easier than threading the list @@ -693,12 +674,85 @@ fn synthesizeLots( source: Source, today: Date, prior_lookup: ?PriorLotsLookup, -) ![]portfolio_mod.Lot { +) ![]Lot { const institution = source.label(); + try requireMapped(io, allocator, positions, account_map, institution); - // First pass: collect any unmapped account numbers so we can - // report all of them at once instead of failing on the first - // and making the user re-run for each. + var lots = std.ArrayList(Lot).empty; + errdefer { + for (lots.items) |lot| freeLot(allocator, lot); + lots.deinit(allocator); + } + + var fresh_note_buf: [64]u8 = undefined; + const fresh_note = try freshNote(&fresh_note_buf, institution, today); + + for (positions) |pos| { + const acct_name = account_map.findByInstitutionAccount(institution, pos.account_number).?; + var lot = brokerage_types.lotFromPosition(pos, acct_name); + lot.symbol = try allocator.dupe(u8, lot.symbol); + lot.account = allocator.dupe(u8, acct_name) catch |err| { + allocator.free(lot.symbol); + return err; + }; + errdefer freeLot(allocator, lot); + try inheritFromPrior(allocator, &lot, prior_lookup, fresh_note); + try lots.append(allocator, lot); + } + + return lots.toOwnedSlice(allocator); +} + +/// The Wells Fargo lots: real per-lot dates and costs from the +/// export (`wells_fargo.parseLots`), then the same prior-file +/// inheritance as every other source. +/// +/// `positions` is the same export's `parsePositions` output, used +/// for the unmapped-account check so the message matches the other +/// sources'. Caller must call `freeLots`. +fn wellsFargoLots( + io: std.Io, + allocator: std.mem.Allocator, + sheet: *const biff8.Sheet, + source_path: []const u8, + positions: []const BrokeragePosition, + account_map: analysis.AccountMap, + today: Date, + prior_lookup: ?PriorLotsLookup, +) ![]Lot { + try requireMapped(io, allocator, positions, account_map, wells_fargo.institution); + + const lots = wells_fargo.parseLots(allocator, sheet, account_map) catch |err| switch (err) { + // requireMapped checked these very account numbers. + error.UnmappedAccount => return err, + else => |e| return badWellsFargoExport(io, source_path, e), + }; + errdefer freeLots(allocator, lots); + + var fresh_note_buf: [64]u8 = undefined; + const fresh_note = try freshNote(&fresh_note_buf, wells_fargo.institution, today); + for (lots) |*lot| try inheritFromPrior(allocator, lot, prior_lookup, fresh_note); + return lots; +} + +/// `note::` stamp for lots with no prior match. Held positions +/// inherit their prior note instead, so the `git diff` between two +/// imports of an unchanged held position stays empty. +fn freshNote(buf: *[64]u8, institution: []const u8, today: Date) ![]const u8 { + return std.fmt.bufPrint(buf, "imported {s} {f}", .{ institution, today }); +} + +/// Refuse the import if any position's account number has no +/// `accounts.srf` entry for `institution`, listing every unmapped +/// number at once rather than failing on the first and making the +/// user re-run for each. +fn requireMapped( + io: std.Io, + allocator: std.mem.Allocator, + positions: []const BrokeragePosition, + account_map: analysis.AccountMap, + institution: []const u8, +) !void { var unmapped: std.ArrayList([]const u8) = .empty; defer unmapped.deinit(allocator); { @@ -713,133 +767,95 @@ fn synthesizeLots( } } } - if (unmapped.items.len > 0) { - // Skip the human-readable error message under tests to keep - // test output clean; the returned error is what the test - // assertions rely on. - if (!builtin.is_test) { - var stderr_buf: [4096]u8 = undefined; - var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); - try stderr_writer.interface.print( - "Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n", - .{ - unmapped.items.len, - if (unmapped.items.len == 1) "" else "s", - institution, - if (unmapped.items.len == 1) "is" else "are", - }, - ); - for (unmapped.items) |num| { - try stderr_writer.interface.print(" - {s}\n", .{num}); - } - try stderr_writer.interface.print( - "\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++ - "`account_number::` value, then rerun the import.\n", - .{institution}, - ); - try stderr_writer.interface.flush(); + if (unmapped.items.len == 0) return; + + // Skip the human-readable error message under tests to keep + // test output clean; the returned error is what the test + // assertions rely on. + if (!builtin.is_test) { + var stderr_buf: [4096]u8 = undefined; + var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); + try stderr_writer.interface.print( + "Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n", + .{ + unmapped.items.len, + if (unmapped.items.len == 1) "" else "s", + institution, + if (unmapped.items.len == 1) "is" else "are", + }, + ); + for (unmapped.items) |num| { + try stderr_writer.interface.print(" - {s}\n", .{num}); } - return error.UnmappedAccount; + try stderr_writer.interface.print( + "\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++ + "`account_number::` value, then rerun the import.\n", + .{institution}, + ); + try stderr_writer.interface.flush(); } + return error.UnmappedAccount; +} - var lots = std.ArrayList(portfolio_mod.Lot).empty; - errdefer { - for (lots.items) |lot| freeLot(allocator, lot); - lots.deinit(allocator); +/// Fill in what an export cannot know from the prior file's lot for +/// the same `(symbol, account)`: +/// +/// - **Open date and price**, only when the export had no buy date +/// (`Date.epoch`, which is every Fidelity/Schwab position and a +/// Wells Fargo `Intra-Day` lot). A lot with a real date keeps it. +/// - **Note**: the prior lot's (carrying its original first-seen +/// date), else `fresh_note`. +/// - **Every hand-edited field** (`Lot.hand_edited_fields`). The +/// set is declared once on `Lot`, so a new hand-edited field is +/// preserved here automatically. None ever appears in an export, +/// so without this a re-import would drop the user's annotations. +/// +/// Cash lots are never matched: they have no account-meaningful +/// identity, and the broker's balance is the truth every time. +/// +/// Strings are duped against `allocator`; a field is only assigned +/// once its copy exists, so `freeLot` is always safe on `lot`. +fn inheritFromPrior( + allocator: std.mem.Allocator, + lot: *Lot, + prior_lookup: ?PriorLotsLookup, + fresh_note: []const u8, +) !void { + const prior: ?*const Lot = if (lot.security_type == .cash) + null + else if (prior_lookup) |pl| + try pl.find(lot.symbol, lot.account.?) + else + null; + + lot.note = try allocator.dupe(u8, if (prior) |prev| prev.note orelse fresh_note else fresh_note); + + const p = prior orelse return; + if (lot.open_date.eql(Date.epoch)) { + lot.open_date = p.open_date; + lot.open_price = p.open_price; } - - // Stamp for newly-introduced lots (no prior match). Held - // positions inherit their prior note instead, so the - // `git diff` between two imports of an unchanged held - // position stays empty. - var fresh_note_buf: [64]u8 = undefined; - const fresh_note = try std.fmt.bufPrint(&fresh_note_buf, "imported {s} {f}", .{ institution, today }); - - for (positions) |pos| { - const acct_name = account_map.findByInstitutionAccount(institution, pos.account_number).?; - - const shares = pos.quantity orelse blk: { - // Cash positions have null quantity; the convention - // elsewhere in zfin (audit, snapshot's cash row) is - // shares=current_value, open_price=$1. Mirror that. - if (pos.is_cash) break :blk pos.current_value orelse 0; - // Non-cash without quantity is malformed export data. - // Treat as 0 shares so the lot is harmless; the user - // will see it in `git diff` and either fix the export - // or hand-edit the lot. - break :blk 0; - }; - - const synthesized_open_price: f64 = if (pos.is_cash) - 1.0 - else if (pos.cost_basis) |cb| - if (shares > 0) cb / shares else 0 - else if (pos.current_value) |cv| - if (shares > 0) cv / shares else 0 - else - 0; - - const security_type: portfolio_mod.LotType = if (pos.is_cash) .cash else .stock; - - // Merge the prior lot's open_date / open_price / note if - // we have one. Cash lots are excluded from `prior_lookup` - // (they have no account-meaningful identity) and always - // get the synthesized values. - const prior: ?*const portfolio_mod.Lot = if (prior_lookup) |pl| - (if (security_type == .cash) null else try pl.find(pos.symbol, acct_name)) - else - null; - - const open_date: Date = if (prior) |p| p.open_date else Date.epoch; - const open_price: f64 = if (prior) |p| p.open_price else synthesized_open_price; - const note_text: []const u8 = if (prior) |p| - (if (p.note) |n| n else fresh_note) - else - fresh_note; - - var new_lot = portfolio_mod.Lot{ - .symbol = try allocator.dupe(u8, pos.symbol), - .shares = shares, - .open_date = open_date, - .open_price = open_price, - .account = try allocator.dupe(u8, acct_name), - .security_type = security_type, - .note = try allocator.dupe(u8, note_text), - }; - - // Carry every hand-edited field forward from the prior lot. - // The set is declared once on `Lot.hand_edited_fields`, so a - // new hand-edited field is preserved here automatically. - // String fields are duped into our allocator; value fields - // (numbers, bools, dates) copy directly. None of these are - // ever present in a brokerage export, so without this a - // re-import would silently drop the user's annotations. - if (prior) |p| { - inline for (portfolio_mod.Lot.hand_edited_fields) |fname| { - if (@TypeOf(@field(p, fname)) == ?[]const u8) { - @field(new_lot, fname) = if (@field(p, fname)) |s| try allocator.dupe(u8, s) else null; - } else { - @field(new_lot, fname) = @field(p, fname); - } - } + // String fields are duped into our allocator; value fields + // (numbers, bools, dates) copy directly. + inline for (Lot.hand_edited_fields) |fname| { + if (@TypeOf(@field(p, fname)) == ?[]const u8) { + @field(lot, fname) = if (@field(p, fname)) |s| try allocator.dupe(u8, s) else null; + } else { + @field(lot, fname) = @field(p, fname); } - - try lots.append(allocator, new_lot); } - - return lots.toOwnedSlice(allocator); } /// Free per-lot allocator-owned strings + the slice. Mirror of the /// internal cleanup in `Portfolio.deinit` (which we'd use directly /// except we don't construct a Portfolio here - `serializePortfolio` /// takes a bare `[]const Lot`). -fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void { +fn freeLots(allocator: std.mem.Allocator, lots: []const Lot) void { for (lots) |lot| freeLot(allocator, lot); allocator.free(lots); } -fn freeLot(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) void { +fn freeLot(allocator: std.mem.Allocator, lot: Lot) void { allocator.free(lot.symbol); if (lot.note) |n| allocator.free(n); if (lot.label) |l| allocator.free(l); @@ -898,7 +914,7 @@ test "synthesizeLots: stock positions get open_price = cost_basis / quantity" { // `open_date` is the sentinel and the note carries the // import date so the user can tell when it was first seen. try testing.expectEqual(Date.epoch.days, lots[0].open_date.days); - try testing.expectEqual(portfolio_mod.LotType.stock, lots[0].security_type); + try testing.expectEqual(LotType.stock, lots[0].security_type); try testing.expect(lots[0].note != null); try testing.expect(std.mem.indexOf(u8, lots[0].note.?, "imported fidelity") != null); try testing.expect(std.mem.indexOf(u8, lots[0].note.?, "2026-05-21") != null); @@ -957,7 +973,7 @@ test "synthesizeLots: cash positions become security_type=cash with shares=value defer freeLots(allocator, lots); try testing.expectEqual(@as(usize, 1), lots.len); - try testing.expectEqual(portfolio_mod.LotType.cash, lots[0].security_type); + try testing.expectEqual(LotType.cash, lots[0].security_type); try testing.expectApproxEqAbs(@as(f64, 5000), lots[0].shares, 0.01); try testing.expectApproxEqAbs(@as(f64, 1.0), lots[0].open_price, 0.01); } @@ -985,7 +1001,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m // by (symbol, account). The merge path should preserve every // field of this lot (open_date, open_price, note) regardless // of the import date. - const prior_lots = [_]portfolio_mod.Lot{ + const prior_lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 1, @@ -1126,7 +1142,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op }); defer account_map.deinit(); - const prior_lots = [_]portfolio_mod.Lot{ + const prior_lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, @@ -1173,7 +1189,7 @@ test "synthesizeLots: every hand-edited field is preserved on re-import" { }); defer account_map.deinit(); - const prior_lots = [_]portfolio_mod.Lot{ + const prior_lots = [_]Lot{ .{ .symbol = "02315N600", .shares = 100, @@ -1250,7 +1266,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n // Prior has only AAPL; new export has both AAPL and a new // GOOG. - const prior_lots = [_]portfolio_mod.Lot{ + const prior_lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, @@ -1296,7 +1312,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e }); defer account_map.deinit(); - const prior_lots = [_]portfolio_mod.Lot{ + const prior_lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 50, @@ -1350,7 +1366,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" { }); defer account_map.deinit(); - const prior_lots = [_]portfolio_mod.Lot{ + const prior_lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, @@ -1391,7 +1407,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot }); defer account_map.deinit(); - const prior_lots = [_]portfolio_mod.Lot{ + const prior_lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, @@ -1432,7 +1448,7 @@ test "PriorLotsLookup: cash lots are excluded from the lookup" { // every time. Pin that they don't enter the lookup so we // don't accidentally inherit a stale cash open_price/note. const allocator = testing.allocator; - const prior_lots = [_]portfolio_mod.Lot{ + const prior_lots = [_]Lot{ .{ .symbol = "FZFXX", .shares = 1000, @@ -1521,20 +1537,200 @@ test "parseArgs: --wells-fargo accepts the lone '-' stdin sentinel" { ctx.io = testing.io; const args = [_][]const u8{ "--wells-fargo", "-" }; const parsed = try parseArgs(&ctx, &args); - switch (parsed.source) { - .wells_fargo => |wf| try testing.expectEqualStrings("-", wf.path), - else => try testing.expect(false), - } + try testing.expectEqualStrings("-", parsed.source.wells_fargo); +} + +test "parseArgs: --account is gone (the WF export names every account)" { + var ctx: framework.RunCtx = undefined; + ctx.io = testing.io; + const args = [_][]const u8{ "--wells-fargo", "wf.xls", "--account", "Sample IRA" }; + try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + +test "parseArgs: --wells-fargo conflicts with another source" { + var ctx: framework.RunCtx = undefined; + ctx.io = testing.io; + const args = [_][]const u8{ "--wells-fargo", "wf.xls", "--fidelity", "f.csv" }; + try testing.expectError(error.ConflictingSources, parseArgs(&ctx, &args)); } 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()); + try testing.expectEqualStrings("wells_fargo", (Source{ .wells_fargo = "" }).label()); } -test "Source.path: returns the CSV path" { +test "Source.path: returns the export 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()); + try testing.expectEqualStrings("/z.xls", (Source{ .wells_fargo = "/z.xls" }).path()); +} + +/// An owned lot, as `wells_fargo.parseLots` returns them. +fn ownedLot(allocator: std.mem.Allocator, lot: Lot) !Lot { + var owned = lot; + owned.symbol = try allocator.dupe(u8, lot.symbol); + owned.account = try allocator.dupe(u8, lot.account.?); + return owned; +} + +test "inheritFromPrior: a lot with a real buy date keeps it, but takes the prior note and annotations" { + const allocator = testing.allocator; + const prior_lots = [_]Lot{.{ + .symbol = "VTI", + .shares = 10, + .open_date = Date.fromYmd(2020, 1, 1), + .open_price = 150, + .account = "Sample IRA", + .note = "imported wells_fargo 2026-01-01", + .label = "Total Market", + .price_ratio = 2.0, + }}; + var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 10, 3)); + defer prior.deinit(); + + var lot = try ownedLot(allocator, .{ .symbol = "VTI", .shares = 8, .open_date = Date.fromYmd(2026, 9, 9), .open_price = 262.5, .account = "Sample IRA" }); + defer freeLot(allocator, lot); + try inheritFromPrior(allocator, &lot, prior, "imported wells_fargo 2026-10-03"); + + try testing.expect(Date.fromYmd(2026, 9, 9).eql(lot.open_date)); + try testing.expectEqual(@as(f64, 262.5), lot.open_price); + try testing.expectEqualStrings("imported wells_fargo 2026-01-01", lot.note.?); + try testing.expectEqualStrings("Total Market", lot.label.?); + try testing.expectEqual(@as(f64, 2.0), lot.price_ratio); +} + +test "inheritFromPrior: an undated lot takes the prior date and price" { + const allocator = testing.allocator; + const prior_lots = [_]Lot{.{ .symbol = "SMPLX", .shares = 250, .open_date = Date.fromYmd(2019, 5, 5), .open_price = 12, .account = "Sample IRA" }}; + var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 10, 3)); + defer prior.deinit(); + + var lot = try ownedLot(allocator, .{ .symbol = "SMPLX", .shares = 250, .open_date = Date.epoch, .open_price = 20, .account = "Sample IRA" }); + defer freeLot(allocator, lot); + try inheritFromPrior(allocator, &lot, prior, "imported wells_fargo 2026-10-03"); + + try testing.expect(Date.fromYmd(2019, 5, 5).eql(lot.open_date)); + try testing.expectEqual(@as(f64, 12), lot.open_price); + // The prior lot had no note, so this one gets today's stamp. + try testing.expectEqualStrings("imported wells_fargo 2026-10-03", lot.note.?); +} + +test "inheritFromPrior: cash never inherits" { + const allocator = testing.allocator; + const prior_lots = [_]Lot{.{ .symbol = "", .shares = 5, .open_date = Date.fromYmd(2019, 5, 5), .open_price = 1, .account = "Sample IRA", .security_type = .cash, .note = "old" }}; + var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 10, 3)); + defer prior.deinit(); + + var lot = try ownedLot(allocator, .{ .symbol = "", .shares = 7, .open_date = Date.epoch, .open_price = 1, .account = "Sample IRA", .security_type = .cash }); + defer freeLot(allocator, lot); + try inheritFromPrior(allocator, &lot, prior, "fresh"); + try testing.expect(Date.epoch.eql(lot.open_date)); + try testing.expectEqualStrings("fresh", lot.note.?); +} + +test "synthesizeLots: every allocation failure is clean" { + const S = struct { + fn run(allocator: std.mem.Allocator, prior: PriorLotsLookup) !void { + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" }, + }; + const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = allocator }; + const positions = [_]BrokeragePosition{ + .{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 1500, .cost_basis = 1500, .is_cash = false }, + .{ .account_number = "Z123", .account_name = "I", .symbol = "02315N600", .description = "", .quantity = 5, .current_value = 500, .cost_basis = 400, .is_cash = false }, + }; + freeLots(allocator, try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior)); + } + }; + const prior_lots = [_]Lot{.{ .symbol = "02315N600", .shares = 5, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 90, .account = "Sample Brokerage", .note = "n", .ticker = "VTTHX", .label = "TGT2035" }}; + var prior = try PriorLotsLookup.init(testing.allocator, &prior_lots, Date.fromYmd(2026, 5, 21)); + defer prior.deinit(); + try testing.checkAllAllocationFailures(testing.allocator, S.run, .{prior}); +} + +// ---- Wells Fargo lots ---- + +fn wfText(s: []const u8) biff8.Cell { + return .{ .text = s }; +} + +fn wfNum(v: f64) biff8.Cell { + return .{ .number = v }; +} + +const wf_header = [_]biff8.Cell{ wfText("Description"), wfText("Symbol"), wfText("Account Number"), wfText("Market Value"), wfText("Shares"), wfText("Total Cost"), wfText("Trade Date") }; + +/// Placeholder export: two VTI lots and an Intra-Day fund in *1234. +const wf_rows = [_][]const biff8.Cell{ + &wf_header, + &.{ wfText("VANGUARD TOTAL STOCK MKT"), wfText("VTI"), wfText("*1234"), wfNum(6000), wfNum(20), wfNum(4500), wfText("Detail") }, + &.{ wfText("VANGUARD TOTAL STOCK MKT"), wfText("VTI"), wfText("*1234"), wfNum(3600), wfNum(12), wfNum(2400), wfText("02/03/2022") }, + &.{ wfText("VANGUARD TOTAL STOCK MKT"), wfText("VTI"), wfText("*1234"), wfNum(2400), wfNum(8), wfNum(2100), wfText("09/09/2026") }, + &.{ wfText("SAMPLE GROWTH FUND"), wfText("SMPLX"), wfText("*1234"), wfNum(5000), wfNum(250), wfText("N/A"), wfText("Intra-Day") }, +}; +const wf_sheet_fixture: biff8.Sheet = .{ .name = wells_fargo.sheet_name, .rows = &wf_rows }; + +test "wellsFargoLots: real lots, with prior notes, annotations, and undated fallbacks" { + const allocator = testing.allocator; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "1234" }, + }; + const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = allocator }; + + const prior_lots = [_]Lot{ + .{ .symbol = "VTI", .shares = 20, .open_date = Date.fromYmd(2022, 2, 3), .open_price = 200, .account = "Sample IRA", .note = "imported wells_fargo 2026-01-01", .label = "Total Market" }, + .{ .symbol = "SMPLX", .shares = 250, .open_date = Date.fromYmd(2018, 8, 8), .open_price = 11, .account = "Sample IRA" }, + }; + var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 10, 3)); + defer prior.deinit(); + + const positions = try wells_fargo.parsePositions(allocator, &wf_sheet_fixture); + defer allocator.free(positions); + const lots = try wellsFargoLots(testing.io, allocator, &wf_sheet_fixture, "wf.xls", positions, account_map, Date.fromYmd(2026, 10, 3), prior); + defer freeLots(allocator, lots); + + try testing.expectEqual(@as(usize, 3), lots.len); + // Dated lots keep the export's own date and cost per share... + try testing.expect(Date.fromYmd(2026, 9, 9).eql(lots[1].open_date)); + try testing.expectEqual(@as(f64, 262.5), lots[1].open_price); + // ...and inherit the position's note and annotations. + try testing.expectEqualStrings("imported wells_fargo 2026-01-01", lots[1].note.?); + try testing.expectEqualStrings("Total Market", lots[1].label.?); + // The Intra-Day fund falls back to the prior lot's date and price. + try testing.expectEqualStrings("SMPLX", lots[2].symbol); + try testing.expect(Date.fromYmd(2018, 8, 8).eql(lots[2].open_date)); + try testing.expectEqual(@as(f64, 11), lots[2].open_price); + try testing.expectEqualStrings("imported wells_fargo 2026-10-03", lots[2].note.?); +} + +test "wellsFargoLots: an unmapped account is refused before any lot is built" { + const allocator = testing.allocator; + const account_map: analysis.AccountMap = .{ .entries = &.{}, .allocator = allocator }; + const positions = try wells_fargo.parsePositions(allocator, &wf_sheet_fixture); + defer allocator.free(positions); + try testing.expectError(error.UnmappedAccount, wellsFargoLots(testing.io, allocator, &wf_sheet_fixture, "wf.xls", positions, account_map, Date.fromYmd(2026, 10, 3), null)); +} + +test "wellsFargoLots: a lot layout parsePositions accepts but parseLots rejects is InvalidExport" { + // parsePositions does not read trade dates; parseLots does. A date + // it cannot read is a wrong-download problem, reported as such. + const allocator = testing.allocator; + const rows = [_][]const biff8.Cell{ + &wf_header, + &.{ wfText("VANGUARD TOTAL STOCK MKT"), wfText("VTI"), wfText("*1234"), wfNum(3600), wfNum(12), wfNum(2400), wfText("Multiple") }, + }; + const sheet: biff8.Sheet = .{ .name = wells_fargo.sheet_name, .rows = &rows }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample IRA", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "1234" }, + }; + const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = allocator }; + const positions = try wells_fargo.parsePositions(allocator, &sheet); + defer allocator.free(positions); + try testing.expectError(error.InvalidExport, wellsFargoLots(testing.io, allocator, &sheet, "wf.xls", positions, account_map, Date.fromYmd(2026, 10, 3), null)); +} + +test "badWellsFargoExport: allocation failure stays an allocation failure" { + try testing.expectError(error.OutOfMemory, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.OutOfMemory))); + try testing.expectError(error.InvalidExport, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.NotCompoundFile))); } diff --git a/src/root.zig b/src/root.zig index 9488505..af919f8 100644 --- a/src/root.zig +++ b/src/root.zig @@ -103,8 +103,9 @@ pub const analysis = @import("analytics/analysis.zig"); /// audit command renders these; downstream tools consume them. pub const reconcile = @import("analytics/reconcile.zig"); -/// Brokerage export parsers (Schwab/Fidelity/Wells Fargo positions + -/// summary) and the normalized `BrokeragePosition` shape. +/// Brokerage export parsers (Schwab/Fidelity positions CSVs + Schwab +/// summary, Wells Fargo positions spreadsheet) and the normalized +/// `BrokeragePosition` shape. pub const brokerage = @import("brokerage.zig"); /// Portfolio loading from srf files: the union-merge loader the CLI uses