import: grounded in Intuit's specs; atomic chart and bank imports
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
New vendored sources (docs/spec/sources/): Intuit's bank CSV upload and
IIF formats, ECMA-376 / Excel number storage, and the IRS form lines the
tax categories map to.
IIF export (rewritten to Intuit's IIF Import Kit):
- declares every account first (!ACCNT NAME, ACCNTTYPE, ACCNUM): an
undeclared account is created in QuickBooks "as a new bank account";
the MCP export types them (cash accounts BANK, the control accounts AR
and AP, registered fixed assets FIXASSET, the rest by type);
- subaccounts by colon path, parents first; colons in a name replaced;
- ASCII only (IIF is ASCII text), dashes and quotes transliterated;
- the kit's 2007+ journal layout and M/D/YYYY dates; an entry using an
undeclared account is refused.
Bank CSV:
- the 4-column layout is money out then money in by position, which
matches Intuit's "Credit, Debit" and banks' "Debit, Credit" both (the
doc's "Expense, Income" labels were wrong, the parsing was not);
- a slash date's order comes from the file (a part over 12 settles it)
or the caller's new date_order, never a guess: Intuit recommends
dd/mm/yyyy, US banks write mm/dd/yyyy;
- fractions of a cent and double negatives ("(-5)") are refused; the
header is recognized on the first non-blank row.
Chart CSV: a blank code or name is refused with its row (found by the
new fuzz_import_csv target), and the core now refuses a blank account
code or name too (replay keeps older books).
MCP:
- books.import_chart_csv and books.import_bank_csv rehearse the whole
file on a copy of the ledger first: a bad row anywhere refuses the
file (before, a duplicate within the file, a parent after its child,
or a date in a closed period left earlier rows behind);
- bank rules: the longest matching pattern wins (they arrive in a map,
so "first match" changed from run to run).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
f1bdac4bca
commit
c72a762ef1
20 files changed
+4154
-182
No files matched your search
@@ -1,12 +1,37 @@
|
||||
//! Parse QuickBooks Online bank/credit-card upload CSVs.
|
||||
//!
|
||||
//! QBO accepts two layouts:
|
||||
//! ```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.
|
||||
//! ```
|
||||
//!
|
||||
//! - 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`.
|
||||
//! 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;
|
||||
@@ -24,6 +49,15 @@ pub struct BankTransaction {
|
||||
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 {
|
||||
@@ -33,94 +67,159 @@ pub enum BankCsvError {
|
||||
/// A date did not parse.
|
||||
#[error("bad date {0:?}")]
|
||||
BadDate(String),
|
||||
/// An amount did not parse.
|
||||
#[error("bad amount {0:?}")]
|
||||
/// 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. Returns transactions in file order.
|
||||
/// 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 out = Vec::new();
|
||||
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
|
||||
}
|
||||
// Skip a header row.
|
||||
if idx == 0 && fields[0].eq_ignore_ascii_case("date") {
|
||||
continue;
|
||||
let first = !seen_row;
|
||||
seen_row = true;
|
||||
if first && fields[0].eq_ignore_ascii_case("date") {
|
||||
continue; // a header row
|
||||
}
|
||||
let (date, description, amount) = match fields.len() {
|
||||
3 => {
|
||||
let amount = parse_amount(fields[2])?;
|
||||
(fields[0], fields[1], amount)
|
||||
}
|
||||
let amount = match fields.len() {
|
||||
3 => parse_amount(fields[2])?,
|
||||
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)?,
|
||||
// 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 expense and income amounts",
|
||||
"row {} has both money out and money in",
|
||||
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,
|
||||
});
|
||||
rows.push((fields[0].to_string(), fields[1].to_string(), amount));
|
||||
}
|
||||
Ok(out)
|
||||
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()
|
||||
}
|
||||
|
||||
fn parse_date(s: &str) -> Result<NaiveDate, BankCsvError> {
|
||||
/// 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();
|
||||
// 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()))
|
||||
let (four, two) = match order {
|
||||
DateOrder::MonthFirst => ("%m/%d/%Y", "%m/%d/%y"),
|
||||
DateOrder::DayFirst => ("%d/%m/%Y", "%d/%m/%y"),
|
||||
};
|
||||
// A four-digit year first; a two-digit year follows POSIX (69-99 are
|
||||
// 1900s), as chrono's %y does.
|
||||
let year_digits = s.rsplit('/').next().map_or(0, str::len);
|
||||
let format = if year_digits == 2 { two } else { four };
|
||||
NaiveDate::parse_from_str(s, format).map_err(|_| BankCsvError::BadDate(s.to_string()))
|
||||
}
|
||||
|
||||
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).
|
||||
// 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(|_| BankCsvError::BadAmount(s.to_string()))?;
|
||||
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;
|
||||
}
|
||||
@@ -143,9 +242,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_column_expense_income() {
|
||||
fn four_column_money_out_then_in() {
|
||||
let csv =
|
||||
"Date,Description,Expense,Income\n2026-01-15,Coffee,4.50,\n2026-01-16,Refund,,12.00\n";
|
||||
"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));
|
||||
|
||||
Reference in new issue
Block a user