From 6e48954190fec503390ec83b7ee2ca315bb3433e Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 18 Jul 2026 12:14:48 -0700 Subject: [PATCH] fidelity detection fix (part 1 - see below) In early July fidelity changed the casing on the CSV headers. This broke the file detection (Schwab/Fidelity) because zfin was doing a case sensitive match on these headers. This quick fix addresses this problem, and a wider fix to make detection less fragile will follow shortly. --- src/brokerage/fidelity.zig | 40 ++++++++++++++++++++++++++++++++-- src/commands/audit/hygiene.zig | 25 ++++++++++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/brokerage/fidelity.zig b/src/brokerage/fidelity.zig index ec9f684..3004833 100644 --- a/src/brokerage/fidelity.zig +++ b/src/brokerage/fidelity.zig @@ -81,11 +81,14 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi var lines = std.mem.splitScalar(u8, content, '\n'); - // Validate header row + // Validate header row. Case-insensitive: Fidelity switched the export + // header from Title Case to sentence case ("Account number,Account + // name,..."). The column order is unchanged, so the positional parsing + // below still holds; only this guard needed to loosen. const header_line = lines.next() orelse return error.EmptyFile; const header_trimmed = std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' }); if (header_trimmed.len == 0) return error.EmptyFile; - if (!std.mem.startsWith(u8, header_trimmed, "Account Number")) { + if (!std.ascii.startsWithIgnoreCase(header_trimmed, "Account Number")) { return error.UnexpectedHeader; } @@ -247,3 +250,36 @@ test "parseCsv cash account type is not cash position" { try std.testing.expect(!positions[0].is_cash); try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01); } + +test "parseCsv accepts Fidelity's lowercase (sentence-case) header" { + // Regression: Fidelity switched the export header from Title Case to + // sentence case ("Account number,Account name,...,Last price,...,Cost + // basis total,Average cost basis,Type"). Column order is unchanged, so + // positional parsing must still work. The trailing comma after the Type + // value and the blank-line-separated legal footer mirror the real export. + const csv = + "Account number,Account name,Symbol,Description,Quantity,Last price,Last price change,Current value,Today's gain/loss dollar,Today's gain/loss percent,Total gain/loss dollar,Total gain/loss percent,Percent of account,Cost basis total,Average cost basis,Type\n" ++ + "Z123,Individual - TOD,FZFXX**,HELD IN MONEY MARKET,,,,$24.03,,,,,0.12%,,,Cash,\n" ++ + "Z123,Individual - TOD,AAPL,APPLE INC,100,$150.00,-$2.00,$15000.00,-$200.00,-1.3%,+$5000.00,+50%,99.88%,$10000.00,$100.00,Margin,\n" ++ + "\n" ++ + "\"Brokerage services are provided by Fidelity Brokerage Services LLC (FBS)...\"\n"; + + const allocator = std.testing.allocator; + const positions = try parseCsv(allocator, csv); + defer allocator.free(positions); + + try std.testing.expectEqual(@as(usize, 2), positions.len); + + // Money-market row -> cash, no quantity. + try std.testing.expectEqualStrings("FZFXX", positions[0].symbol); + try std.testing.expect(positions[0].is_cash); + try std.testing.expect(positions[0].quantity == null); + try std.testing.expectApproxEqAbs(@as(f64, 24.03), positions[0].current_value.?, 0.01); + + // Stock row -> positional columns still resolve correctly. + try std.testing.expectEqualStrings("AAPL", positions[1].symbol); + try std.testing.expect(!positions[1].is_cash); + try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01); +} diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index b295cce..e588063 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -59,9 +59,14 @@ fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind { else data; - // Fidelity CSV: first line starts with "Account Number" or "Account Name" - if (std.mem.startsWith(u8, content, "Account Number") or - std.mem.startsWith(u8, content, "Account Name")) + // Fidelity CSV: first line starts with "Account Number"/"Account Name". + // Case-insensitive: Fidelity switched the export header from Title Case + // to sentence case ("Account number,Account name,...") and could change + // it again. Matching this loosely also keeps the file from falling + // through to the broad schwab-summary heuristic below, which would + // otherwise misclassify it via the "Brokerage services..." legal footer. + if (std.ascii.startsWithIgnoreCase(content, "Account Number") or + std.ascii.startsWithIgnoreCase(content, "Account Name")) return .fidelity_csv; // Schwab per-account CSV: starts with a quoted title line like "Positions for ..." @@ -1077,6 +1082,20 @@ test "detectBrokerFileKind: fidelity csv with BOM" { try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_bom).?); } +test "detectBrokerFileKind: fidelity csv with lowercase header is not misclassified as schwab" { + // Regression: Fidelity switched the header from Title Case to sentence + // case ("Account number,..."). The old case-sensitive check failed and + // the file fell through to the schwab-summary heuristic, which + // false-matched on the "Brokerage services are provided by..." legal + // footer plus the "$" amounts. Must classify as fidelity_csv. + const data = + "Account number,Account name,Symbol,Description,Quantity,Last price,Last price change,Current value,Today's gain/loss dollar,Today's gain/loss percent,Total gain/loss dollar,Total gain/loss percent,Percent of account,Cost basis total,Average cost basis,Type\n" ++ + "Z123,Individual - TOD,AAPL,APPLE INC,100,$150.00,-$2.00,$15000.00,,,,,99.88%,$10000.00,$100.00,Margin,\n" ++ + "\n" ++ + "\"Brokerage services are provided by Fidelity Brokerage Services LLC (FBS)...\" $\n"; + try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(data).?); +} + test "detectBrokerFileKind: schwab csv" { const schwab_header = "\"Positions for account Roth IRA ...1234 as of\""; try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?);