Files
wanderbooks/crates/ledger-import/src/bank_csv.rs
T
swaitsandClaude Opus 5.5 11e33edc60
check / check (push) Canceled after 0s
import: spec-blind suite (40 tests); POSIX two-digit years; exact sub-cent text
tests/spec_import.rs, written from the import crate's specs and sources
alone: every statement foots as printed on random books (P&L, balance
sheet with and without members, cash flow, trial balance, members'
equity), format_amount round-trips, bank CSV round-trips in both layouts
with date-order scenarios, IIF transactions sum to zero with every
account declared and ASCII fields, XLSX cells read back through a small
zip/DEFLATE reader, PDF labels with Typst markup render as text and the
bytes are deterministic, chart CSV and templates.

Its two findings, both fixed:
- "1/15/69" parsed as 2069: chrono's %y cuts over at 70, POSIX strptime
  at 69 (69-99 are 1900s). Two-digit years are computed by POSIX now.
- An amount finer than a cent went into an XLSX as rounded text ("1.01"
  for 1.005); it is written as its exact text now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:17:02 -06:00

273 lines
10 KiB
Rust

//! Parse QuickBooks Online bank/credit-card upload CSVs.
//!
//! ```text
//! GOAL: read a bank or card export into dated, signed transactions, never
//! guessing at what a cell means.
//! SOURCE: docs/spec/sources/intuit-bank-csv.md (Intuit, "Format CSV files
//! in Excel to get bank transactions into QuickBooks"; "Manually
//! upload transactions into QuickBooks Online").
//! INVARIANTS:
//! 1. Two layouts, by column count (Intuit): 3 columns Date, Description,
//! Amount (a payment negative, a deposit positive); 4 columns Date,
//! Description, then money out, then money in, each positive with the
//! other cell blank. Intuit heads those two "Credit, Debit" (its example
//! puts the payment under Credit and the deposit under Debit); bank
//! exports often head them "Debit, Credit" in the bank's sense; both
//! put money out third, so the columns are read by position.
//! 2. Every transaction MUST carry a nonzero amount in whole cents; a zero
//! row is skipped (Intuit: leave zero cells blank).
//! 3. A slash date's order (month or day first) MUST come from the file or
//! the caller, never a guess: Intuit recommends dd/mm/yyyy while US
//! banks write mm/dd/yyyy, and a date like 3/4/2026 reads both ways.
//! A part over 12 settles the order for the whole file; a file that
//! settles both ways is refused; a file that settles neither needs the
//! caller's order. ISO dates (2026-03-04) need none.
//! 4. A two-digit year follows the POSIX rule (69-99 are 1900s, 00-68
//! 2000s).
//! 5. NEVER accept an amount with a fraction of a cent, a double negative
//! ("(-5)", "--5"), or both of a 4-column row's amounts.
//! ```
//!
//! Amounts may carry `$`, thousands commas, and parentheses for negatives
//! (common in bank exports; Intuit's pages are silent on these: CONVENTION).
//! A header row is optional: the first non-blank row is a header when its
//! first cell says "date".
use chrono::NaiveDate;
use rust_decimal::Decimal;
use std::str::FromStr;
use thiserror::Error;
/// One parsed bank transaction.
#[derive(Clone, Debug, PartialEq)]
pub struct BankTransaction {
/// Transaction date.
pub date: NaiveDate,
/// Bank's description.
pub description: String,
/// Signed amount: positive = money in, negative = money out.
pub amount: Decimal,
}
/// Which part of a slash date (3/4/2026) is the month.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateOrder {
/// mm/dd/yyyy (US banks).
MonthFirst,
/// dd/mm/yyyy (Intuit's recommendation; most of the world).
DayFirst,
}
/// Parse errors.
#[derive(Debug, Error, PartialEq)]
pub enum BankCsvError {
/// The CSV has neither 3 nor 4 columns.
#[error("expected 3 or 4 columns, found {0}")]
BadColumnCount(usize),
/// A date did not parse.
#[error("bad date {0:?}")]
BadDate(String),
/// An amount did not parse, or isn't whole cents.
#[error("bad amount {0:?}: expected a number of dollars and cents")]
BadAmount(String),
/// The file's slash dates read month-first in one place and day-first
/// in another.
#[error("the dates disagree: {0:?} must be day first, {1:?} month first")]
DatesDisagree(String, String),
/// The file's slash dates read either way, and no order was given.
#[error(
"the dates read either way (month or day first, e.g. {0:?}); say which order the bank uses"
)]
DateOrderUnknown(String),
/// The CSV itself is malformed.
#[error("csv error: {0}")]
Csv(String),
}
/// Parse a QBO bank CSV, reading slash dates in the order the file itself
/// settles. Returns transactions in file order.
pub fn parse_bank_csv(text: &str) -> Result<Vec<BankTransaction>, BankCsvError> {
parse_bank_csv_with(text, None)
}
/// [`parse_bank_csv`], with the caller's date order for a file whose dates
/// read either way. A file that settles its own order must agree with it.
pub fn parse_bank_csv_with(
text: &str,
order: Option<DateOrder>,
) -> Result<Vec<BankTransaction>, BankCsvError> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.trim(csv::Trim::All)
.from_reader(text.as_bytes());
let mut rows: Vec<(String, String, Decimal)> = Vec::new();
let mut seen_row = false;
for (idx, record) in reader.records().enumerate() {
let record = record.map_err(|e| BankCsvError::Csv(e.to_string()))?;
let fields: Vec<&str> = record.iter().collect();
if fields.iter().all(|f| f.is_empty()) {
continue; // skip blank lines
}
let first = !seen_row;
seen_row = true;
if first && fields[0].eq_ignore_ascii_case("date") {
continue; // a header row
}
let amount = match fields.len() {
3 => parse_amount(fields[2])?,
4 => {
// Money out in the third column, money in in the fourth.
match (!fields[2].is_empty(), !fields[3].is_empty()) {
(true, false) => -parse_amount(fields[2])?,
(false, true) => parse_amount(fields[3])?,
(false, false) => Decimal::ZERO,
(true, true) => {
return Err(BankCsvError::Csv(format!(
"row {} has both money out and money in",
idx + 1
)));
}
}
}
n => return Err(BankCsvError::BadColumnCount(n)),
};
if amount.is_zero() {
continue; // zero-amount rows carry no money
}
rows.push((fields[0].to_string(), fields[1].to_string(), amount));
}
let order = date_order(rows.iter().map(|r| r.0.as_str()), order)?;
rows.into_iter()
.map(|(date, description, amount)| {
Ok(BankTransaction {
date: parse_date(&date, order)?,
description,
amount,
})
})
.collect()
}
/// The (month-or-day, day-or-month) parts of a slash date, if it is one.
fn slash_parts(s: &str) -> Option<(u32, u32)> {
let mut it = s.trim().split('/');
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
it.next()?; // a year
Some((a, b))
}
/// The order the file's slash dates settle, or the caller's (INVARIANT 3).
fn date_order<'a>(
dates: impl Iterator<Item = &'a str>,
given: Option<DateOrder>,
) -> Result<DateOrder, BankCsvError> {
let (mut day_first, mut month_first, mut ambiguous) = (None, None, None);
for d in dates {
match slash_parts(d) {
Some((a, _)) if a > 12 => day_first = day_first.or(Some(d.to_string())),
Some((_, b)) if b > 12 => month_first = month_first.or(Some(d.to_string())),
Some((a, b)) if a != b => ambiguous = ambiguous.or(Some(d.to_string())),
_ => {}
}
}
let settled = match (day_first, month_first) {
(Some(d), Some(m)) => return Err(BankCsvError::DatesDisagree(d, m)),
(Some(_), None) => Some(DateOrder::DayFirst),
(None, Some(_)) => Some(DateOrder::MonthFirst),
(None, None) => None,
};
match (settled, given, ambiguous) {
(Some(s), Some(g), _) if s != g => Err(BankCsvError::DatesDisagree(
format!("the file reads {s:?}"),
format!("the caller said {g:?}"),
)),
(Some(s), _, _) => Ok(s),
(None, Some(g), _) => Ok(g),
// Nothing to disambiguate (ISO dates, or day equals month).
(None, None, None) => Ok(DateOrder::MonthFirst),
(None, None, Some(example)) => Err(BankCsvError::DateOrderUnknown(example)),
}
}
fn parse_date(s: &str, order: DateOrder) -> Result<NaiveDate, BankCsvError> {
let s = s.trim();
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Ok(d);
}
let four = match order {
DateOrder::MonthFirst => "%m/%d/%Y",
DateOrder::DayFirst => "%d/%m/%Y",
};
let bad = || BankCsvError::BadDate(s.to_string());
let (rest, year) = s.rsplit_once('/').ok_or_else(bad)?;
if year.len() != 2 {
return NaiveDate::parse_from_str(s, four).map_err(|_| bad());
}
// A two-digit year follows POSIX strptime %y: 69-99 are 1900s, 00-68
// 2000s. (chrono's %y puts the cutoff at 70; found by the spec-blind
// suite, 2026-10-13.)
let yy: i32 = year.parse().map_err(|_| bad())?;
let full = if yy >= 69 { 1900 + yy } else { 2000 + yy };
NaiveDate::parse_from_str(&format!("{rest}/{full}"), four).map_err(|_| bad())
}
fn parse_amount(s: &str) -> Result<Decimal, BankCsvError> {
let bad = || BankCsvError::BadAmount(s.to_string());
let cleaned: String = s.trim().replace(['$', ','], "");
// Parentheses mean negative: (123.45). Inside them, no sign.
let (neg, inner) = match cleaned.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
Some(inner) if inner.starts_with(['-', '+']) => return Err(bad()),
Some(inner) => (true, inner),
None => (false, cleaned.as_str()),
};
let mut amount = Decimal::from_str(inner).map_err(|_| bad())?;
// Whole cents only: a bank moves no fraction of one.
if amount.normalize().scale() > 2 {
return Err(bad());
}
if neg {
amount = -amount;
}
Ok(amount)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn three_column_with_header() {
let csv =
"Date,Description,Amount\n2026-01-15,Whole Foods,-84.32\n01/16/2026,Paycheck,2500.00\n";
let txns = parse_bank_csv(csv).unwrap();
assert_eq!(txns.len(), 2);
assert_eq!(txns[0].description, "Whole Foods");
assert_eq!(txns[0].amount, Decimal::new(-8432, 2));
assert_eq!(txns[1].amount, Decimal::new(250000, 2));
}
#[test]
fn four_column_money_out_then_in() {
let csv =
"Date,Description,Credit,Debit\n2026-01-15,Coffee,4.50,\n2026-01-16,Refund,,12.00\n";
let txns = parse_bank_csv(csv).unwrap();
assert_eq!(txns.len(), 2);
assert_eq!(txns[0].amount, Decimal::new(-450, 2));
assert_eq!(txns[1].amount, Decimal::new(1200, 2));
}
#[test]
fn parenthesized_negatives_and_dollar_signs() {
let csv = "01/15/2026,Store,\"($1,234.56)\"\n";
let txns = parse_bank_csv(csv).unwrap();
assert_eq!(txns[0].amount, Decimal::new(-123456, 2));
}
#[test]
fn bad_column_count_rejected() {
let csv = "a,b\n";
assert_eq!(parse_bank_csv(csv), Err(BankCsvError::BadColumnCount(2)));
}
}