From 899da76042add66a91059e80e1d907c33d89a308 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 23 May 2026 12:48:23 -0700 Subject: [PATCH] second wells fargo import format --- src/brokerage/wells_fargo.zig | 130 +++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 11 deletions(-) diff --git a/src/brokerage/wells_fargo.zig b/src/brokerage/wells_fargo.zig index 2916fcd..cde5c59 100644 --- a/src/brokerage/wells_fargo.zig +++ b/src/brokerage/wells_fargo.zig @@ -133,21 +133,38 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo // Description: first non-empty line after the anchor. const description = nextNonEmpty(staged.items, &cur) orelse break; - // Trade-date column: either `Multiple(N)` or `MM/DD/YYYY`. - // We don't use the value but consuming it advances `cur` - // to the shares line. Single-date format pastes have an - // extra blank line between the description and the date, - // so step past blanks. - _ = nextNonEmpty(staged.items, &cur) orelse break; + // 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 with optional thousands commas, no $. + // 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: line starts with `@ $`. + // 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; - if (!std.mem.startsWith(u8, cost_text, "@ ")) continue; - const avg_cost = parseDollarAmount(cost_text[2..]) orelse continue; + 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 @@ -175,7 +192,13 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo .description = description, .quantity = shares, .current_value = market_value, - .cost_basis = shares * avg_cost, + // 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), }); } @@ -1006,6 +1029,91 @@ test "parsePaste: cash section absent is a no-op" { 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"));