diff --git a/crates/ledger-import/src/bank_csv.rs b/crates/ledger-import/src/bank_csv.rs new file mode 100644 index 0000000..258dc54 --- /dev/null +++ b/crates/ledger-import/src/bank_csv.rs @@ -0,0 +1,167 @@ +//! Parse QuickBooks Online bank/credit-card upload CSVs. +//! +//! QBO accepts two layouts: +//! +//! - 3 columns: `Date, Description, Amount` (positive = money in). +//! - 4 columns: `Date, Description, Expense, Income` (or `Debit, Credit`). +//! +//! Headers are optional; the layout is detected from the column count. +//! Dates accept `YYYY-MM-DD`, `MM/DD/YYYY`, and `MM/DD/YY`. + +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, +} + +/// 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. + #[error("bad amount {0:?}")] + BadAmount(String), + /// The CSV itself is malformed. + #[error("csv error: {0}")] + Csv(String), +} + +/// Parse a QBO bank CSV. Returns transactions in file order. +pub fn parse_bank_csv(text: &str) -> Result, BankCsvError> { + let mut reader = csv::ReaderBuilder::new() + .has_headers(false) + .trim(csv::Trim::All) + .from_reader(text.as_bytes()); + let mut out = Vec::new(); + 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 + } + // Skip a header row. + if idx == 0 && fields[0].eq_ignore_ascii_case("date") { + continue; + } + let (date, description, amount) = match fields.len() { + 3 => { + let amount = parse_amount(fields[2])?; + (fields[0], fields[1], amount) + } + 4 => { + // Expense/Debit in col 2, Income/Credit in col 3. Exactly one + // should be non-empty; a row with both is ambiguous. + let out_amt = fields[2].trim(); + let in_amt = fields[3].trim(); + let amount = match (!out_amt.is_empty(), !in_amt.is_empty()) { + (true, false) => -parse_amount(out_amt)?, + (false, true) => parse_amount(in_amt)?, + (false, false) => Decimal::ZERO, + (true, true) => { + return Err(BankCsvError::Csv(format!( + "row {} has both expense and income amounts", + idx + 1 + ))); + } + }; + (fields[0], fields[1], amount) + } + n => return Err(BankCsvError::BadColumnCount(n)), + }; + if amount.is_zero() { + continue; // zero-amount rows carry no money + } + out.push(BankTransaction { + date: parse_date(date)?, + description: description.to_string(), + amount, + }); + } + Ok(out) +} + +fn parse_date(s: &str) -> Result { + let s = s.trim(); + // YYYY-MM-DD + if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + return Ok(d); + } + // MM/DD/YYYY + if let Ok(d) = NaiveDate::parse_from_str(s, "%m/%d/%Y") { + return Ok(d); + } + // MM/DD/YY (assume 2000s) + if let Ok(d) = NaiveDate::parse_from_str(s, "%m/%d/%y") { + return Ok(d); + } + Err(BankCsvError::BadDate(s.to_string())) +} + +fn parse_amount(s: &str) -> Result { + let cleaned: String = s.trim().replace(['$', ','], ""); + // Parentheses mean negative: (123.45). + let (neg, inner) = match cleaned.strip_prefix('(').and_then(|t| t.strip_suffix(')')) { + Some(inner) => (true, inner), + None => (false, cleaned.as_str()), + }; + let mut amount = + Decimal::from_str(inner).map_err(|_| BankCsvError::BadAmount(s.to_string()))?; + 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_expense_income() { + let csv = + "Date,Description,Expense,Income\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))); + } +}