|
|
|
|
@ -2,14 +2,47 @@
|
|
|
|
|
//!
|
|
|
|
|
//! Parses two distinct Schwab inputs:
|
|
|
|
|
//!
|
|
|
|
|
//! 1. The per-account positions CSV exported from Schwab's website
|
|
|
|
|
//! (Accounts -> Positions -> Export). One file per account.
|
|
|
|
|
//! 1. The positions CSV exported from Schwab's website (Accounts ->
|
|
|
|
|
//! Positions -> Export). Comes in two layouts, both read by
|
|
|
|
|
//! `parseCsv`, depending on the account dropdown:
|
|
|
|
|
//! - one account: a single section, account named in the title;
|
|
|
|
|
//! - "All Brokerage Accounts": one section per account.
|
|
|
|
|
//!
|
|
|
|
|
//! 2. The freeform account-summary text the user pastes from
|
|
|
|
|
//! Schwab's Accounts overview page. One paste covers all
|
|
|
|
|
//! accounts at once but only carries cash + total-value
|
|
|
|
|
//! aggregates, no per-position detail.
|
|
|
|
|
//!
|
|
|
|
|
//! ## Schwab CSV - layout
|
|
|
|
|
//!
|
|
|
|
|
//! Single account:
|
|
|
|
|
//!
|
|
|
|
|
//! "Positions for account <NAME> ...<NUM> as of <TIME>, <YYYY/MM/DD>"
|
|
|
|
|
//! <blank>
|
|
|
|
|
//! "Symbol","Description",...,"Asset Type",
|
|
|
|
|
//! <position rows>
|
|
|
|
|
//! "Cash & Cash Investments",...
|
|
|
|
|
//! "Positions Total",...
|
|
|
|
|
//!
|
|
|
|
|
//! All accounts - the same section repeated, each opened by an account
|
|
|
|
|
//! line, sections separated by blank lines:
|
|
|
|
|
//!
|
|
|
|
|
//! "Positions for All-Accounts as of <TIME>, <MM/DD/YYYY>"
|
|
|
|
|
//! <blank>
|
|
|
|
|
//! "<NAME> ...<NUM>"
|
|
|
|
|
//! "Symbol","Description",...,"Asset Type",
|
|
|
|
|
//! <position rows>
|
|
|
|
|
//! "Cash & Cash Investments",...
|
|
|
|
|
//! "Positions Total",...
|
|
|
|
|
//! <blank>
|
|
|
|
|
//! <blank>
|
|
|
|
|
//! "<NAME> ...<NUM>"
|
|
|
|
|
//! ...
|
|
|
|
|
//!
|
|
|
|
|
//! Account names in the all-accounts layout carry underscores where the
|
|
|
|
|
//! single-account title has spaces. Names are display-only - matching
|
|
|
|
|
//! against `accounts.srf` is by number - so they are passed through as-is.
|
|
|
|
|
//!
|
|
|
|
|
//! ## Schwab CSV - limitations
|
|
|
|
|
//!
|
|
|
|
|
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
|
|
|
|
@ -19,17 +52,21 @@
|
|
|
|
|
//! quotes ("value with ""quotes"" inside") or multi-line quoted fields.
|
|
|
|
|
//! Schwab's export does not use these in practice.
|
|
|
|
|
//!
|
|
|
|
|
//! 3. The account number and name are extracted from the title line:
|
|
|
|
|
//! "Positions for account <NAME> ...<NUM> as of ..."
|
|
|
|
|
//! 3. Every position row is stamped with the account of the section it
|
|
|
|
|
//! sits in. The grouping by account happens downstream, in
|
|
|
|
|
//! `reconcile/common.compareAccounts`.
|
|
|
|
|
//!
|
|
|
|
|
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
|
|
|
|
|
//! The row with symbol "Positions Total" is skipped.
|
|
|
|
|
//! The row with symbol "Positions Total" is not a position: it is
|
|
|
|
|
//! checked against the market values summed above it (see
|
|
|
|
|
//! `checkTotal`), so a mis-split row fails loudly as
|
|
|
|
|
//! `error.InconsistentExport`.
|
|
|
|
|
//!
|
|
|
|
|
//! 5. Hardcodes the expected column layout via fixed indices. The header
|
|
|
|
|
//! row is validated (see `validateHeader`) to confirm the columns the
|
|
|
|
|
//! parser reads are where it expects them, so a Schwab reorder/rename of
|
|
|
|
|
//! a read-column fails loudly as `error.UnexpectedHeader` instead of
|
|
|
|
|
//! silently mis-reading a value.
|
|
|
|
|
//! 5. Hardcodes the expected column layout via fixed indices. Every
|
|
|
|
|
//! section's header row is validated (see `validateHeader`) to confirm
|
|
|
|
|
//! the columns the parser reads are where it expects them, so a Schwab
|
|
|
|
|
//! reorder/rename of a read-column fails loudly as
|
|
|
|
|
//! `error.UnexpectedHeader` instead of silently mis-reading a value.
|
|
|
|
|
//!
|
|
|
|
|
//! ## Schwab summary - limitations
|
|
|
|
|
//!
|
|
|
|
|
@ -60,12 +97,16 @@
|
|
|
|
|
//! account number line (the last whitespace-separated token).
|
|
|
|
|
|
|
|
|
|
const std = @import("std");
|
|
|
|
|
const builtin = @import("builtin");
|
|
|
|
|
const Money = @import("../Money.zig");
|
|
|
|
|
const portfolio_mod = @import("../models/portfolio.zig");
|
|
|
|
|
const types = @import("types.zig");
|
|
|
|
|
|
|
|
|
|
const BrokeragePosition = types.BrokeragePosition;
|
|
|
|
|
const parseDollarAmount = types.parseDollarAmount;
|
|
|
|
|
|
|
|
|
|
const log = std.log.scoped(.schwab);
|
|
|
|
|
|
|
|
|
|
const expected_columns = 17;
|
|
|
|
|
|
|
|
|
|
const Col = struct {
|
|
|
|
|
@ -149,66 +190,141 @@ fn splitCsvLine(line: []const u8, cols: *[expected_columns][]const u8) usize {
|
|
|
|
|
return col_count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract account name and number from Schwab title line.
|
|
|
|
|
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
|
|
|
|
|
/// Returns {name, number} or null if the line doesn't match.
|
|
|
|
|
fn parseTitle(line: []const u8) ?struct { name: []const u8, number: []const u8 } {
|
|
|
|
|
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
|
|
|
|
|
const prefix = "Positions for account ";
|
|
|
|
|
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
|
|
|
|
|
const rest = stripped[prefix.len..];
|
|
|
|
|
|
|
|
|
|
// Find "..." which separates name from account number
|
|
|
|
|
const dots_idx = std.mem.indexOf(u8, rest, "...") orelse return null;
|
|
|
|
|
const name = std.mem.trimEnd(u8, rest[0..dots_idx], &.{' '});
|
|
|
|
|
|
|
|
|
|
// Account number: after "..." until " as of" or end
|
|
|
|
|
const after_dots = rest[dots_idx + 3 ..];
|
|
|
|
|
const as_of_idx = std.mem.indexOf(u8, after_dots, " as of") orelse after_dots.len;
|
|
|
|
|
const number = std.mem.trim(u8, after_dots[0..as_of_idx], &.{' '});
|
|
|
|
|
/// The account a run of position rows belongs to.
|
|
|
|
|
const Account = struct { name: []const u8, number: []const u8 };
|
|
|
|
|
|
|
|
|
|
/// Split Schwab's "<NAME> ...<NUM>" account label. The number is what
|
|
|
|
|
/// follows the last "..." (numbers never contain dots, names might); the
|
|
|
|
|
/// name is everything before it. Null unless both halves are non-empty
|
|
|
|
|
/// and the number is a single token.
|
|
|
|
|
fn parseAccountLabel(text: []const u8) ?Account {
|
|
|
|
|
const stripped = std.mem.trim(u8, text, " ");
|
|
|
|
|
const dots_idx = std.mem.lastIndexOf(u8, stripped, "...") orelse return null;
|
|
|
|
|
const name = std.mem.trimEnd(u8, stripped[0..dots_idx], " ");
|
|
|
|
|
const number = stripped[dots_idx + 3 ..];
|
|
|
|
|
if (name.len == 0 or number.len == 0) return null;
|
|
|
|
|
if (std.mem.indexOfAny(u8, number, " ,\t") != null) return null;
|
|
|
|
|
return .{ .name = name, .number = number };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parsed Schwab per-account positions CSV. Returned `positions`
|
|
|
|
|
/// slice is heap-allocated; string fields slice into `data`.
|
|
|
|
|
pub const CsvResult = struct {
|
|
|
|
|
positions: []BrokeragePosition,
|
|
|
|
|
account_name: []const u8,
|
|
|
|
|
account_number: []const u8,
|
|
|
|
|
/// What the title line says about where account identity comes from.
|
|
|
|
|
const Title = union(enum) {
|
|
|
|
|
/// "Positions for account <NAME> ...<NUM> as of ..." - one account,
|
|
|
|
|
/// named here.
|
|
|
|
|
account: Account,
|
|
|
|
|
/// "Positions for All-Accounts as of ..." - each section names its
|
|
|
|
|
/// own account on a "<NAME> ...<NUM>" line.
|
|
|
|
|
all_accounts,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
|
|
|
|
|
/// Classify a Schwab positions title line. Null if it is neither layout.
|
|
|
|
|
fn parseTitle(line: []const u8) ?Title {
|
|
|
|
|
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
|
|
|
|
|
if (std.mem.startsWith(u8, stripped, "Positions for All-Accounts")) return .all_accounts;
|
|
|
|
|
|
|
|
|
|
const prefix = "Positions for account ";
|
|
|
|
|
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
|
|
|
|
|
const rest = stripped[prefix.len..];
|
|
|
|
|
const label_end = std.mem.indexOf(u8, rest, " as of") orelse rest.len;
|
|
|
|
|
return .{ .account = parseAccountLabel(rest[0..label_end]) orelse return null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Running tally of one section's position rows, checked against the
|
|
|
|
|
/// section's "Positions Total" row.
|
|
|
|
|
const Section = struct {
|
|
|
|
|
market_value: f64 = 0,
|
|
|
|
|
rows: usize = 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Check a section's "Positions Total" market value against the rows
|
|
|
|
|
/// summed above it. Each row is rounded to the cent, so the sum may drift
|
|
|
|
|
/// from Schwab's total by at most half a cent per row; anything more
|
|
|
|
|
/// means rows were mis-split or mis-attributed. A total that does not
|
|
|
|
|
/// parse is not checked.
|
|
|
|
|
fn checkTotal(account: Account, section: Section, total_raw: []const u8) error{InconsistentExport}!void {
|
|
|
|
|
const total = parseDollarAmount(total_raw) orelse return;
|
|
|
|
|
const tolerance = 0.005 * @as(f64, @floatFromInt(section.rows)) + 1e-6;
|
|
|
|
|
if (@abs(total - section.market_value) <= tolerance) return;
|
|
|
|
|
if (!builtin.is_test)
|
|
|
|
|
log.warn("account ...{s}: Positions Total is {f} but its rows sum to {f}", .{ account.number, Money.from(total), Money.from(section.market_value) });
|
|
|
|
|
return error.InconsistentExport;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Ways a Schwab positions CSV can fail to parse.
|
|
|
|
|
pub const CsvError = error{
|
|
|
|
|
EmptyFile,
|
|
|
|
|
/// The title is neither positions layout, or a section's header row
|
|
|
|
|
/// is missing, short, or has a read-column out of place.
|
|
|
|
|
UnexpectedHeader,
|
|
|
|
|
/// An all-accounts export has a row before any "<NAME> ...<NUM>"
|
|
|
|
|
/// account line, so there is no account to attribute it to.
|
|
|
|
|
MissingAccountLine,
|
|
|
|
|
/// An all-accounts export with no account sections at all.
|
|
|
|
|
NoAccountsFound,
|
|
|
|
|
/// A "Positions Total" row disagrees with the rows above it.
|
|
|
|
|
InconsistentExport,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Parse a Schwab positions CSV in either layout (see the module doc).
|
|
|
|
|
/// Every position carries its own account, so callers never need to know
|
|
|
|
|
/// which layout it was. The returned slice is heap-allocated; string
|
|
|
|
|
/// fields slice into `data`.
|
|
|
|
|
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) (CsvError || std.mem.Allocator.Error)![]BrokeragePosition {
|
|
|
|
|
var positions = std.ArrayList(BrokeragePosition).empty;
|
|
|
|
|
errdefer positions.deinit(allocator);
|
|
|
|
|
|
|
|
|
|
if (std.mem.trim(u8, data, " \r\n").len == 0) return error.EmptyFile;
|
|
|
|
|
var lines = std.mem.splitScalar(u8, data, '\n');
|
|
|
|
|
|
|
|
|
|
// Line 1: title with account name and number
|
|
|
|
|
const title_line = lines.next() orelse return error.EmptyFile;
|
|
|
|
|
const title = parseTitle(title_line) orelse return error.UnexpectedHeader;
|
|
|
|
|
const title = parseTitle(lines.first()) orelse return error.UnexpectedHeader;
|
|
|
|
|
|
|
|
|
|
// Line 2: blank (skip)
|
|
|
|
|
_ = lines.next();
|
|
|
|
|
// Line 3: header row - validate the columns we read are where we expect,
|
|
|
|
|
// so a Schwab format change surfaces as UnexpectedHeader (and, in the
|
|
|
|
|
// flagless audit, a "could not parse" warning) rather than silently
|
|
|
|
|
// reading a shifted column.
|
|
|
|
|
const header_line = lines.next() orelse return error.UnexpectedHeader;
|
|
|
|
|
try validateHeader(std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' }));
|
|
|
|
|
// The account the rows being read belong to: named by the title for
|
|
|
|
|
// a single-account export, by each section's account line otherwise.
|
|
|
|
|
var account: ?Account = switch (title) {
|
|
|
|
|
.account => |a| a,
|
|
|
|
|
.all_accounts => null,
|
|
|
|
|
};
|
|
|
|
|
// Every section opens with a header row, validated before any of its
|
|
|
|
|
// rows are read so a Schwab format change surfaces as
|
|
|
|
|
// UnexpectedHeader (and, in the flagless audit, a "could not parse"
|
|
|
|
|
// warning) rather than silently reading a shifted column.
|
|
|
|
|
var expect_header = account != null;
|
|
|
|
|
var section: Section = .{};
|
|
|
|
|
var section_count: usize = 0;
|
|
|
|
|
|
|
|
|
|
// Data rows
|
|
|
|
|
while (lines.next()) |line| {
|
|
|
|
|
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
|
|
|
|
if (trimmed.len == 0) continue;
|
|
|
|
|
|
|
|
|
|
var cols: [expected_columns][]const u8 = undefined;
|
|
|
|
|
const col_count = splitCsvLine(trimmed, &cols);
|
|
|
|
|
|
|
|
|
|
if (title == .all_accounts and col_count == 1) {
|
|
|
|
|
if (parseAccountLabel(cols[0])) |a| {
|
|
|
|
|
account = a;
|
|
|
|
|
expect_header = true;
|
|
|
|
|
section = .{};
|
|
|
|
|
section_count += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const current = account orelse return error.MissingAccountLine;
|
|
|
|
|
|
|
|
|
|
if (expect_header) {
|
|
|
|
|
try validateHeader(trimmed);
|
|
|
|
|
expect_header = false;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (col_count < expected_columns) continue;
|
|
|
|
|
|
|
|
|
|
const symbol = cols[Col.symbol];
|
|
|
|
|
if (symbol.len == 0) continue;
|
|
|
|
|
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
|
|
|
|
|
if (std.mem.eql(u8, symbol, "Positions Total")) {
|
|
|
|
|
try checkTotal(current, section, cols[Col.market_value]);
|
|
|
|
|
section = .{};
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
|
|
|
|
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
|
|
|
|
@ -217,24 +333,25 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
|
|
|
|
|
// "cash" in the account.
|
|
|
|
|
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
|
|
|
|
portfolio_mod.isMoneyMarketSymbol(symbol);
|
|
|
|
|
const market_value = parseDollarAmount(cols[Col.market_value]);
|
|
|
|
|
|
|
|
|
|
try positions.append(allocator, .{
|
|
|
|
|
.account_number = title.number,
|
|
|
|
|
.account_name = title.name,
|
|
|
|
|
.account_number = current.number,
|
|
|
|
|
.account_name = current.name,
|
|
|
|
|
.symbol = symbol,
|
|
|
|
|
.description = if (col_count > 1) cols[1] else "",
|
|
|
|
|
.description = cols[1],
|
|
|
|
|
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
|
|
|
|
|
.current_value = parseDollarAmount(cols[Col.market_value]),
|
|
|
|
|
.current_value = market_value,
|
|
|
|
|
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
|
|
|
|
|
.is_cash = is_cash,
|
|
|
|
|
});
|
|
|
|
|
section.market_value += market_value orelse 0;
|
|
|
|
|
section.rows += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return .{
|
|
|
|
|
.positions = try positions.toOwnedSlice(allocator),
|
|
|
|
|
.account_name = title.name,
|
|
|
|
|
.account_number = title.number,
|
|
|
|
|
};
|
|
|
|
|
if (title == .all_accounts and section_count == 0) return error.NoAccountsFound;
|
|
|
|
|
|
|
|
|
|
return positions.toOwnedSlice(allocator);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Account-level summary from a Schwab paste (no per-position detail).
|
|
|
|
|
@ -336,17 +453,41 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu
|
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
test "parseTitle" {
|
|
|
|
|
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"");
|
|
|
|
|
try std.testing.expect(t1 != null);
|
|
|
|
|
try std.testing.expectEqualStrings("Sample Trust", t1.?.name);
|
|
|
|
|
try std.testing.expectEqualStrings("1234", t1.?.number);
|
|
|
|
|
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"").?;
|
|
|
|
|
try std.testing.expectEqualStrings("Sample Trust", t1.account.name);
|
|
|
|
|
try std.testing.expectEqualStrings("1234", t1.account.number);
|
|
|
|
|
|
|
|
|
|
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"");
|
|
|
|
|
try std.testing.expect(t2 != null);
|
|
|
|
|
try std.testing.expectEqualStrings("Sample IRA", t2.?.name);
|
|
|
|
|
try std.testing.expectEqualStrings("5678", t2.?.number);
|
|
|
|
|
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"").?;
|
|
|
|
|
try std.testing.expectEqualStrings("Sample IRA", t2.account.name);
|
|
|
|
|
try std.testing.expectEqualStrings("5678", t2.account.number);
|
|
|
|
|
|
|
|
|
|
// The all-accounts title names no account; its date is MM/DD/YYYY.
|
|
|
|
|
try std.testing.expect(parseTitle("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"").? == .all_accounts);
|
|
|
|
|
|
|
|
|
|
try std.testing.expect(parseTitle("some random text") == null);
|
|
|
|
|
// Single-account title without the "...NUM" label.
|
|
|
|
|
try std.testing.expect(parseTitle("\"Positions for account Sample IRA as of 3:00 PM ET, 2026/04/10\"") == null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseAccountLabel" {
|
|
|
|
|
const a = parseAccountLabel("Sample_IRA ...1234").?;
|
|
|
|
|
try std.testing.expectEqualStrings("Sample_IRA", a.name);
|
|
|
|
|
try std.testing.expectEqualStrings("1234", a.number);
|
|
|
|
|
|
|
|
|
|
// Spaces in names are kept; surrounding space is trimmed.
|
|
|
|
|
const b = parseAccountLabel(" Sample Brokerage ...Z123 ").?;
|
|
|
|
|
try std.testing.expectEqualStrings("Sample Brokerage", b.name);
|
|
|
|
|
try std.testing.expectEqualStrings("Z123", b.number);
|
|
|
|
|
|
|
|
|
|
// The number follows the LAST "...", so dots in a name survive.
|
|
|
|
|
const c = parseAccountLabel("Sample... Trust ...5678").?;
|
|
|
|
|
try std.testing.expectEqualStrings("Sample... Trust", c.name);
|
|
|
|
|
try std.testing.expectEqualStrings("5678", c.number);
|
|
|
|
|
|
|
|
|
|
try std.testing.expect(parseAccountLabel("Sample IRA") == null); // no dots
|
|
|
|
|
try std.testing.expect(parseAccountLabel("...1234") == null); // no name
|
|
|
|
|
try std.testing.expect(parseAccountLabel("Sample IRA ...") == null); // no number
|
|
|
|
|
try std.testing.expect(parseAccountLabel("Sample IRA ...12 34") == null); // not one token
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "splitCsvLine" {
|
|
|
|
|
@ -368,29 +509,32 @@ test "parseCsv basic" {
|
|
|
|
|
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
|
|
|
|
|
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
|
|
|
|
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
|
|
|
|
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
|
|
|
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$364,212.62\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
|
|
|
|
|
|
|
|
|
const allocator = std.testing.allocator;
|
|
|
|
|
const parsed = try parseCsv(allocator, csv);
|
|
|
|
|
defer allocator.free(parsed.positions);
|
|
|
|
|
const positions = try parseCsv(allocator, csv);
|
|
|
|
|
defer allocator.free(positions);
|
|
|
|
|
|
|
|
|
|
try std.testing.expectEqualStrings("Sample Trust", parsed.account_name);
|
|
|
|
|
try std.testing.expectEqualStrings("1234", parsed.account_number);
|
|
|
|
|
try std.testing.expectEqual(@as(usize, 2), positions.len);
|
|
|
|
|
|
|
|
|
|
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
|
|
|
|
|
// Every row carries the title's account.
|
|
|
|
|
for (positions) |p| {
|
|
|
|
|
try std.testing.expectEqualStrings("Sample Trust", p.account_name);
|
|
|
|
|
try std.testing.expectEqualStrings("1234", p.account_number);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stock position
|
|
|
|
|
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
|
|
|
|
try std.testing.expect(!parsed.positions[0].is_cash);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), parsed.positions[0].current_value.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
|
|
|
|
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
|
|
|
|
try std.testing.expect(!positions[0].is_cash);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1488), positions[0].quantity.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), positions[0].current_value.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), positions[0].cost_basis.?, 0.01);
|
|
|
|
|
|
|
|
|
|
// Cash
|
|
|
|
|
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
|
|
|
|
|
try std.testing.expect(parsed.positions[1].is_cash);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
|
|
|
|
|
try std.testing.expect(parsed.positions[1].quantity == null);
|
|
|
|
|
try std.testing.expectEqualStrings("Cash & Cash Investments", positions[1].symbol);
|
|
|
|
|
try std.testing.expect(positions[1].is_cash);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), positions[1].current_value.?, 0.01);
|
|
|
|
|
try std.testing.expect(positions[1].quantity == null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv accepts the real Short (Long) header labels" {
|
|
|
|
|
@ -403,14 +547,14 @@ test "parseCsv accepts the real Short (Long) header labels" {
|
|
|
|
|
"\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
|
|
|
|
"\"AMZN\",\"AMAZON.COM INC\",\"5.55\",\"2.38%\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
|
|
|
|
const allocator = std.testing.allocator;
|
|
|
|
|
const parsed = try parseCsv(allocator, csv);
|
|
|
|
|
defer allocator.free(parsed.positions);
|
|
|
|
|
const positions = try parseCsv(allocator, csv);
|
|
|
|
|
defer allocator.free(positions);
|
|
|
|
|
|
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
|
|
|
|
|
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), parsed.positions[0].cost_basis.?, 0.01);
|
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
|
|
|
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[0].cost_basis.?, 0.01);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv rejects a header whose read-columns are reordered" {
|
|
|
|
|
@ -434,13 +578,13 @@ test "parseCsv tolerates reordering of columns it does not read" {
|
|
|
|
|
"\"Symbol\",\"Description\",\"Price Chng % (Price Change %)\",\"Price Chng $ (Price Change $)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
|
|
|
|
"\"AMZN\",\"AMAZON.COM INC\",\"2.38%\",\"5.55\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
|
|
|
|
const allocator = std.testing.allocator;
|
|
|
|
|
const parsed = try parseCsv(allocator, csv);
|
|
|
|
|
defer allocator.free(parsed.positions);
|
|
|
|
|
const positions = try parseCsv(allocator, csv);
|
|
|
|
|
defer allocator.free(positions);
|
|
|
|
|
|
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
|
|
|
|
|
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
|
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
|
|
|
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv rejects a header with too few columns" {
|
|
|
|
|
@ -452,6 +596,166 @@ test "parseCsv rejects a header with too few columns" {
|
|
|
|
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, csv));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv rejects an empty file and an unrecognized title" {
|
|
|
|
|
const allocator = std.testing.allocator;
|
|
|
|
|
try std.testing.expectError(error.EmptyFile, parseCsv(allocator, ""));
|
|
|
|
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, "\"Positions for nobody\"\n"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv rejects a single-account file with no header" {
|
|
|
|
|
const allocator = std.testing.allocator;
|
|
|
|
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, "\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n\n\"AMZN\"\n"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The real "Short (Long)" header, as every section of an export carries it.
|
|
|
|
|
const test_header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
|
|
|
|
|
|
|
|
|
|
/// A three-section all-accounts export (fictional figures). The Trust
|
|
|
|
|
/// section exercises the two oddities real exports carry: a cost basis
|
|
|
|
|
/// Schwab reports as "Unavailable", and a holding with no price or value
|
|
|
|
|
/// ("N/A").
|
|
|
|
|
const test_all_accounts =
|
|
|
|
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\"Sample_IRA ...1234\"\n" ++
|
|
|
|
|
test_header ++
|
|
|
|
|
"\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"96.77%\",\"ETFs & Closed End Funds\",\n" ++
|
|
|
|
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"3.23%\",\"Cash and Money Market\",\n" ++
|
|
|
|
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$110.00\",\"0.36%\",\"$31,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\"Sample_Brokerage ...5678\"\n" ++
|
|
|
|
|
test_header ++
|
|
|
|
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
|
|
|
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"33.33%\",\"Cash and Money Market\",\n" ++
|
|
|
|
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.67%\",\"$3,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\"Sample_Trust ...9012\"\n" ++
|
|
|
|
|
test_header ++
|
|
|
|
|
"\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.36%\",\"27.50\",\"200\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"Unavailable\",\"--\",\"--\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
|
|
|
|
|
"\"123ABC456\",\"SAMPLE CORP ESCROW\",\"--\",\"--\",\"N/A\",\"5\",\"--\",\"--\",\"N/A\",\"$0.00\",\"--\",\"--\",\"--\",\"No\",\"N/A\",\"--\",\"Equity\",\n" ++
|
|
|
|
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$0.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0%\",\"Cash and Money Market\",\n" ++
|
|
|
|
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$0.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
|
|
|
|
|
|
|
|
|
test "parseCsv all-accounts: every row carries its own section's account" {
|
|
|
|
|
const allocator = std.testing.allocator;
|
|
|
|
|
const positions = try parseCsv(allocator, test_all_accounts);
|
|
|
|
|
defer allocator.free(positions);
|
|
|
|
|
|
|
|
|
|
const Expect = struct { symbol: []const u8, number: []const u8, name: []const u8, is_cash: bool };
|
|
|
|
|
const expected = [_]Expect{
|
|
|
|
|
.{ .symbol = "VTI", .number = "1234", .name = "Sample_IRA", .is_cash = false },
|
|
|
|
|
.{ .symbol = "Cash & Cash Investments", .number = "1234", .name = "Sample_IRA", .is_cash = true },
|
|
|
|
|
.{ .symbol = "AAPL", .number = "5678", .name = "Sample_Brokerage", .is_cash = false },
|
|
|
|
|
.{ .symbol = "Cash & Cash Investments", .number = "5678", .name = "Sample_Brokerage", .is_cash = true },
|
|
|
|
|
.{ .symbol = "SCHD", .number = "9012", .name = "Sample_Trust", .is_cash = false },
|
|
|
|
|
.{ .symbol = "123ABC456", .number = "9012", .name = "Sample_Trust", .is_cash = false },
|
|
|
|
|
.{ .symbol = "Cash & Cash Investments", .number = "9012", .name = "Sample_Trust", .is_cash = true },
|
|
|
|
|
};
|
|
|
|
|
try std.testing.expectEqual(expected.len, positions.len);
|
|
|
|
|
for (expected, positions) |e, p| {
|
|
|
|
|
try std.testing.expectEqualStrings(e.symbol, p.symbol);
|
|
|
|
|
try std.testing.expectEqualStrings(e.number, p.account_number);
|
|
|
|
|
try std.testing.expectEqualStrings(e.name, p.account_name);
|
|
|
|
|
try std.testing.expectEqual(e.is_cash, p.is_cash);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.001);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 30000), positions[0].current_value.?, 0.001);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1000), positions[3].current_value.?, 0.001);
|
|
|
|
|
// "Unavailable" cost basis and "N/A" value read as unknown, not zero.
|
|
|
|
|
try std.testing.expect(positions[4].cost_basis == null);
|
|
|
|
|
try std.testing.expect(positions[5].current_value == null);
|
|
|
|
|
try std.testing.expectApproxEqAbs(@as(f64, 5), positions[5].quantity.?, 0.001);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv all-accounts: a row before any account line has no account" {
|
|
|
|
|
const csv =
|
|
|
|
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
test_header ++
|
|
|
|
|
"\"Sample_IRA ...1234\"\n";
|
|
|
|
|
try std.testing.expectError(error.MissingAccountLine, parseCsv(std.testing.allocator, csv));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv all-accounts: every section's header is validated" {
|
|
|
|
|
// The second section's Symbol and Price are swapped; the first is fine.
|
|
|
|
|
const csv =
|
|
|
|
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\"Sample_IRA ...1234\"\n" ++
|
|
|
|
|
test_header ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\"Sample_Brokerage ...5678\"\n" ++
|
|
|
|
|
"\"Price\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Symbol\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n";
|
|
|
|
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(std.testing.allocator, csv));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv all-accounts: an account line must be followed by a header" {
|
|
|
|
|
const csv =
|
|
|
|
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\"Sample_IRA ...1234\"\n" ++
|
|
|
|
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n";
|
|
|
|
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(std.testing.allocator, csv));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv all-accounts: no sections is an error, not an empty result" {
|
|
|
|
|
try std.testing.expectError(
|
|
|
|
|
error.NoAccountsFound,
|
|
|
|
|
parseCsv(std.testing.allocator, "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n"),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv: a Positions Total that disagrees with its rows is rejected" {
|
|
|
|
|
// Both layouts check it. Single account: the total claims $1 more than
|
|
|
|
|
// the one row.
|
|
|
|
|
const single =
|
|
|
|
|
"\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
test_header ++
|
|
|
|
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
|
|
|
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,001.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
|
|
|
|
try std.testing.expectError(error.InconsistentExport, parseCsv(std.testing.allocator, single));
|
|
|
|
|
|
|
|
|
|
// All accounts: the total is only checked against its own section, so
|
|
|
|
|
// a row attributed to the wrong section shows up. Here the second
|
|
|
|
|
// section's total is the first section's.
|
|
|
|
|
const all =
|
|
|
|
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
|
|
|
|
"\n" ++
|
|
|
|
|
"\"Sample_IRA ...1234\"\n" ++
|
|
|
|
|
test_header ++
|
|
|
|
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
|
|
|
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
|
|
|
|
"\"Sample_Brokerage ...5678\"\n" ++
|
|
|
|
|
test_header ++
|
|
|
|
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"100%\",\"Cash and Money Market\",\n" ++
|
|
|
|
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$2,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
|
|
|
|
try std.testing.expectError(error.InconsistentExport, parseCsv(std.testing.allocator, all));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "checkTotal: allows half a cent of rounding per row" {
|
|
|
|
|
const acct: Account = .{ .name = "Sample IRA", .number = "1234" };
|
|
|
|
|
// Three rows: up to 1.5 cents of drift is rounding.
|
|
|
|
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$100.01");
|
|
|
|
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$99.99");
|
|
|
|
|
try std.testing.expectError(error.InconsistentExport, checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$100.02"));
|
|
|
|
|
// A total Schwab leaves blank is not checked.
|
|
|
|
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "--");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseCsv all-accounts: allocation failures are handled" {
|
|
|
|
|
const S = struct {
|
|
|
|
|
fn run(allocator: std.mem.Allocator) !void {
|
|
|
|
|
const positions = try parseCsv(allocator, test_all_accounts);
|
|
|
|
|
allocator.free(positions);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test "parseSummary basic" {
|
|
|
|
|
const data =
|
|
|
|
|
\\Sample Roth
|
|
|
|
|
|