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
178 lines
5.8 KiB
Rust
178 lines
5.8 KiB
Rust
//! Parse chart-of-accounts CSVs.
|
|
//!
|
|
//! Expected layout (header row required):
|
|
//!
|
|
//! ```csv
|
|
//! code,name,type,parent_code
|
|
//! 1000,Cash,asset,
|
|
//! 1010,Checking,asset,1000
|
|
//! ```
|
|
//!
|
|
//! `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`
|
|
//! (case-insensitive). `parent_code` is optional; empty means top-level.
|
|
|
|
use thiserror::Error;
|
|
|
|
/// One chart row.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ChartRow {
|
|
/// Account code.
|
|
pub code: String,
|
|
/// Account name.
|
|
pub name: String,
|
|
/// Account type, lowercase: asset, liability, equity, revenue, expense.
|
|
pub account_type: String,
|
|
/// Parent account code, if any.
|
|
pub parent_code: Option<String>,
|
|
/// Optional fifth column: the tax category key (see
|
|
/// `ledger_core::tax`).
|
|
pub tax_category: Option<String>,
|
|
}
|
|
|
|
/// Parse errors.
|
|
#[derive(Debug, Error, PartialEq)]
|
|
pub enum ChartCsvError {
|
|
/// Wrong column count: (found, expected by the header).
|
|
#[error("expected {1} columns to match the header, found {0}")]
|
|
BadColumnCount(usize, usize),
|
|
/// Missing or bad header.
|
|
#[error("bad header: expected code,name,type,parent_code[,tax_category]")]
|
|
BadHeader,
|
|
/// Unknown account type.
|
|
#[error("bad account type {0:?}: expected asset, liability, equity, revenue, or expense")]
|
|
BadType(String),
|
|
/// A row's code or name is blank (row numbers count the header as 1).
|
|
#[error("row {row}: the {field} is blank")]
|
|
Blank {
|
|
/// The row, counting the header as row 1.
|
|
row: usize,
|
|
/// "code" or "name".
|
|
field: &'static str,
|
|
},
|
|
/// Malformed CSV.
|
|
#[error("csv error: {0}")]
|
|
Csv(String),
|
|
}
|
|
|
|
/// Parse a chart CSV. Returns rows in file order (parents should come first,
|
|
/// but order is not validated here).
|
|
pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
|
|
let mut reader = csv::ReaderBuilder::new()
|
|
.has_headers(false)
|
|
.flexible(true)
|
|
.trim(csv::Trim::All)
|
|
.from_reader(text.as_bytes());
|
|
let mut out = Vec::new();
|
|
let mut width = 4;
|
|
for (idx, record) in reader.records().enumerate() {
|
|
let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?;
|
|
let fields: Vec<&str> = record.iter().collect();
|
|
if fields.iter().all(|f| f.is_empty()) {
|
|
continue;
|
|
}
|
|
if idx == 0 {
|
|
let lower: Vec<String> = fields.iter().map(|f| f.to_lowercase()).collect();
|
|
width = if lower == ["code", "name", "type", "parent_code"] {
|
|
4
|
|
} else if lower == ["code", "name", "type", "parent_code", "tax_category"] {
|
|
5
|
|
} else {
|
|
return Err(ChartCsvError::BadHeader);
|
|
};
|
|
continue;
|
|
}
|
|
if fields.len() != width {
|
|
return Err(ChartCsvError::BadColumnCount(fields.len(), width));
|
|
}
|
|
for (field, value) in [("code", fields[0]), ("name", fields[1])] {
|
|
if value.trim().is_empty() {
|
|
return Err(ChartCsvError::Blank {
|
|
row: idx + 1,
|
|
field,
|
|
});
|
|
}
|
|
}
|
|
let account_type = fields[2].to_lowercase();
|
|
match account_type.as_str() {
|
|
"asset" | "liability" | "equity" | "revenue" | "expense" => {}
|
|
_ => return Err(ChartCsvError::BadType(fields[2].to_string())),
|
|
}
|
|
let parent_code = fields[3].trim();
|
|
out.push(ChartRow {
|
|
code: fields[0].to_string(),
|
|
name: fields[1].to_string(),
|
|
account_type,
|
|
parent_code: if parent_code.is_empty() {
|
|
None
|
|
} else {
|
|
Some(parent_code.to_string())
|
|
},
|
|
tax_category: fields
|
|
.get(4)
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_string),
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_chart() {
|
|
let csv = "code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n4000,Revenue,revenue,\n";
|
|
let rows = parse_chart_csv(csv).unwrap();
|
|
assert_eq!(rows.len(), 3);
|
|
assert_eq!(rows[0].parent_code, None);
|
|
assert_eq!(rows[1].parent_code, Some("1000".to_string()));
|
|
assert_eq!(rows[2].account_type, "revenue");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_optional_tax_category_column() {
|
|
let csv = "code,name,type,parent_code,tax_category\n4000,Sales,revenue,,gross_receipts\n1000,Cash,asset,,\n";
|
|
let rows = parse_chart_csv(csv).unwrap();
|
|
assert_eq!(rows[0].tax_category.as_deref(), Some("gross_receipts"));
|
|
assert_eq!(rows[1].tax_category, None);
|
|
// Rows must match the header's width.
|
|
let short = "code,name,type,parent_code,tax_category\n1000,Cash,asset,\n";
|
|
assert_eq!(
|
|
parse_chart_csv(short),
|
|
Err(ChartCsvError::BadColumnCount(4, 5))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_type_rejected() {
|
|
let csv = "code,name,type,parent_code\n1000,Cash,money,\n";
|
|
assert!(matches!(
|
|
parse_chart_csv(csv),
|
|
Err(ChartCsvError::BadType(_))
|
|
));
|
|
}
|
|
|
|
/// Found by fuzz_import_csv (2026-10-13): a row with a blank code or
|
|
/// name was accepted.
|
|
#[test]
|
|
fn a_blank_code_or_name_is_refused_with_its_row() {
|
|
let header = "code,name,type,parent_code\n";
|
|
assert_eq!(
|
|
parse_chart_csv(&format!("{header}1000,Cash,asset,\n,Rent,expense,\n")),
|
|
Err(ChartCsvError::Blank {
|
|
row: 3,
|
|
field: "code"
|
|
})
|
|
);
|
|
assert_eq!(
|
|
parse_chart_csv(&format!("{header}1000, ,asset,\n")),
|
|
Err(ChartCsvError::Blank {
|
|
row: 2,
|
|
field: "name"
|
|
})
|
|
);
|
|
}
|
|
}
|