import: grounded in Intuit's specs; atomic chart and bank imports
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:
swaitsandClaude Opus 5.5 committed 2026-10-13 20:47:41 -06:00
1 parent f1bdac4bca
commit c72a762ef1
20 files changed
+4154 -182

No files matched your search

+5
View File
@@ -195,6 +195,11 @@ pub enum LedgerError {
#[error("account code must not be empty")]
EmptyAccountCode,
/// An account's name is empty or only spaces: a chart lists every
/// account by number and title.
#[error("account name must not be empty")]
EmptyAccountName,
/// The referenced parent account does not exist.
#[error("unknown parent account id {0}")]
UnknownParentAccount(u64),
+10 -1
View File
@@ -670,6 +670,15 @@ impl Ledger {
parent: Option<AccountId>,
placeholder: bool,
) -> Result<AccountId, LedgerError> {
// A chart lists each account by number and title; neither may be
// blank (2026-10-13; the core refused only an empty code, so a code
// of spaces or an unnamed account got in).
if code.trim().is_empty() {
return Err(LedgerError::EmptyAccountCode);
}
if name.trim().is_empty() {
return Err(LedgerError::EmptyAccountName);
}
if let Some(p) = parent.and_then(|p| self.chart.get(p))
&& p.account_type != account_type
{
@@ -684,7 +693,7 @@ impl Ledger {
}
/// [`Self::add_account`] without the rules added on 2026-10-13 (a
/// child's type), for replaying journals written before them. Never
/// child's type; a blank code or name), for replaying journals written before them. Never
/// for new work.
pub fn replay_add_account(
&mut self,
+149 -50
View File
@@ -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));
+37
View File
@@ -41,6 +41,14 @@ pub enum ChartCsvError {
/// 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),
@@ -76,6 +84,14 @@ pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
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" => {}
@@ -137,4 +153,25 @@ mod tests {
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"
})
);
}
}
+233 -86
View File
@@ -1,70 +1,223 @@
//! Generate Intuit Interchange Format (IIF) journal entries for QuickBooks
//! Desktop, which many CPAs still run.
//!
//! Layout per entry (fields are tab-separated; `\t` marks each tab):
//!
//! ```text
//! !TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO
//! !SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO
//! !ENDTRNS
//! TRNS\tGENERAL JOURNAL\t01/15/2026\tCash\t\t1000.00\t1\tMemo
//! SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00\t1\tMemo
//! ENDTRNS
//! GOAL: hand a CPA's QuickBooks Desktop the posted entries, landing on the
//! right accounts, with nothing invented.
//! SOURCE: docs/spec/sources/intuit-iif.md (Intuit's IIF Overview, the IIF
//! Import Kit's header help and its 2007+ General Journal example).
//! INVARIANTS:
//! 1. Accounts MUST be declared first (`!ACCNT`: NAME, ACCNTTYPE, ACCNUM)
//! BECAUSE QuickBooks creates an unknown account "as a new bank
//! account", and the kit says lists come before transactions.
//! 2. A subaccount's NAME MUST be its path, highest level first, joined by
//! colons; a colon inside one account's own name is replaced, or it
//! would read as another level.
//! 3. Each transaction is a TRNS line, its SPL lines, then ENDTRNS, all
//! `GENERAL JOURNAL`, dated M/D/YYYY (the kit's 2007+ example), with
//! debits positive and credits negative, so the lines sum to zero.
//! 4. Fields MUST hold no tab or line break (no escape exists) and only
//! ASCII (IIF is ASCII text): common punctuation is transliterated,
//! anything else becomes '?'.
//! 5. NEVER write an entry whose account wasn't declared: refuse instead.
//! ```
//!
//! Amounts are signed from the entry's perspective: debits positive,
//! credits negative. Dates are `MM/DD/YYYY`. Accounts are referenced by
//! name (QuickBooks matches on name).
//! Layout (each `→` is a tab):
//!
//! ```text
//! !ACCNT→NAME→ACCNTTYPE→ACCNUM
//! ACCNT→Cash→BANK→1000
//! !TRNS→TRNSID→TRNSTYPE→DATE→ACCNT→CLASS→AMOUNT→DOCNUM→MEMO
//! !SPL→SPLID→TRNSTYPE→DATE→ACCNT→CLASS→AMOUNT→DOCNUM→MEMO
//! !ENDTRNS
//! TRNS→→GENERAL JOURNAL→1/15/2026→Cash→→1000.00→1→Sale
//! SPL→→GENERAL JOURNAL→1/15/2026→Revenue→→-1000.00→1→Sale
//! ENDTRNS
//! ```
use ledger_core::{JournalEntry, Side};
use chrono::Datelike;
use ledger_core::{AccountId, JournalEntry, Ledger, Side};
use rust_decimal::Decimal;
use std::collections::HashMap;
use thiserror::Error;
/// One entry's data for IIF export.
pub struct IifEntry<'a> {
/// The journal entry.
pub entry: &'a JournalEntry,
/// Account id → account name, for resolving line accounts.
pub account_names: &'a dyn Fn(ledger_core::AccountId) -> String,
/// A QuickBooks account type: the IIF `ACCNTTYPE` keywords (Intuit's IIF
/// Import Kit, `!ACCNT` header help). Wanderbooks' five types don't say
/// which of these an account is (checking is `BANK`, not "other current
/// asset"), so the caller decides.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QbAccountType {
/// `BANK`: checking or savings.
Bank,
/// `AR`: accounts receivable.
AccountsReceivable,
/// `OCASSET`: other current asset.
OtherCurrentAsset,
/// `FIXASSET`: fixed asset.
FixedAsset,
/// `OASSET`: other asset.
OtherAsset,
/// `AP`: accounts payable.
AccountsPayable,
/// `CCARD`: credit card.
CreditCard,
/// `OCLIAB`: other current liability.
OtherCurrentLiability,
/// `LTLIAB`: long term liability.
LongTermLiability,
/// `EQUITY`: capital/equity.
Equity,
/// `INC`: income.
Income,
/// `COGS`: cost of goods sold.
CostOfGoodsSold,
/// `EXP`: expense.
Expense,
/// `EXINC`: other income.
OtherIncome,
/// `EXEXP`: other expense.
OtherExpense,
}
/// Render posted entries as IIF text. `docnum` numbers entries sequentially
/// from 1 in the order given.
pub fn journal_to_iif(entries: &[IifEntry<'_>]) -> String {
impl QbAccountType {
/// The IIF keyword.
pub fn keyword(self) -> &'static str {
match self {
Self::Bank => "BANK",
Self::AccountsReceivable => "AR",
Self::OtherCurrentAsset => "OCASSET",
Self::FixedAsset => "FIXASSET",
Self::OtherAsset => "OASSET",
Self::AccountsPayable => "AP",
Self::CreditCard => "CCARD",
Self::OtherCurrentLiability => "OCLIAB",
Self::LongTermLiability => "LTLIAB",
Self::Equity => "EQUITY",
Self::Income => "INC",
Self::CostOfGoodsSold => "COGS",
Self::Expense => "EXP",
Self::OtherIncome => "EXINC",
Self::OtherExpense => "EXEXP",
}
}
}
/// One account as QuickBooks will know it.
#[derive(Clone, Debug, PartialEq)]
pub struct IifAccount {
/// The wanderbooks account.
pub id: AccountId,
/// Its name with its parents', highest first, joined by colons, each
/// already made safe for IIF ([`iif_accounts`] builds these).
pub path: String,
/// Its account number (ACCNUM): the wanderbooks code.
pub number: String,
/// Its QuickBooks type.
pub qb_type: QbAccountType,
}
/// Errors.
#[derive(Debug, Error, PartialEq)]
pub enum IifError {
/// An entry uses an account that wasn't declared.
#[error("entry {entry} uses account {account}, which isn't in the account list")]
UndeclaredAccount {
/// The entry.
entry: u64,
/// The account.
account: AccountId,
},
}
/// Every account in the chart as QuickBooks will know it: its colon path
/// (parents first) and number, with `qb_type` deciding its QuickBooks type.
pub fn iif_accounts(
ledger: &Ledger,
qb_type: impl Fn(&ledger_core::Account) -> QbAccountType,
) -> Vec<IifAccount> {
let path = |mut id: AccountId| {
let mut names = Vec::new();
// A chart's parents form a tree; the bound guards a malformed one.
for _ in 0..64 {
let Some(a) = ledger.chart().get(id) else {
break;
};
names.push(field(&a.name).replace(':', "-"));
match a.parent {
Some(p) => id = p,
None => break,
}
}
names.reverse();
names.join(":")
};
let mut out: Vec<IifAccount> = ledger
.chart()
.all()
.map(|a| IifAccount {
id: a.id,
path: path(a.id),
number: field(&a.code),
qb_type: qb_type(a),
})
.collect();
out.sort_by(|a, b| a.number.cmp(&b.number));
out
}
/// Render posted entries as IIF: the accounts declared first, then one
/// transaction per entry. `DOCNUM` numbers entries from 1 in the order
/// given.
pub fn journal_to_iif(
accounts: &[IifAccount],
entries: &[&JournalEntry],
) -> Result<String, IifError> {
let by_id: HashMap<AccountId, &IifAccount> = accounts.iter().map(|a| (a.id, a)).collect();
let mut out = String::new();
out.push_str("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO\n");
out.push_str("!SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO\n");
out.push_str("!ACCNT\tNAME\tACCNTTYPE\tACCNUM\n");
for a in accounts {
out.push_str(&format!(
"ACCNT\t{}\t{}\t{}\n",
a.path,
a.qb_type.keyword(),
a.number
));
}
out.push_str("!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\n");
out.push_str("!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\n");
out.push_str("!ENDTRNS\n");
for (idx, item) in entries.iter().enumerate() {
for (idx, entry) in entries.iter().enumerate() {
let docnum = idx + 1;
let date = item.entry.date().format("%m/%d/%Y").to_string();
let memo = sanitize(&item.entry.description().replace('\t', " "));
let mut lines: Vec<_> = item.entry.lines().iter().collect();
// TRNS carries the first debit (or first line); the rest are SPLs.
let d = entry.date();
let date = format!("{}/{}/{}", d.month(), d.day(), d.year());
let memo = field(entry.description());
// TRNS carries the first debit; the rest are SPLs.
let mut lines: Vec<_> = entry.lines().iter().collect();
lines.sort_by_key(|l| match l.side {
Side::Debit => 0,
Side::Credit => 1,
});
let (first, rest) = lines.split_first().expect("entry has lines");
let first_acct = sanitize(&(item.account_names)(first.account));
let first_amount = signed(first.side, first.amount.amount);
out.push_str(&format!(
"TRNS\tGENERAL JOURNAL\t{date}\t{first_acct}\t\t{}\t{docnum}\t{memo}\n",
format_amount(first_amount),
));
for line in rest {
let acct = sanitize(&(item.account_names)(line.account));
let amount = signed(line.side, line.amount.amount);
for (n, line) in lines.iter().enumerate() {
let account = by_id
.get(&line.account)
.ok_or(IifError::UndeclaredAccount {
entry: entry.id(),
account: line.account,
})?;
let keyword = if n == 0 { "TRNS" } else { "SPL" };
out.push_str(&format!(
"SPL\tGENERAL JOURNAL\t{date}\t{acct}\t{}\t{docnum}\t{memo}\n",
format_amount(amount),
"{keyword}\t\tGENERAL JOURNAL\t{date}\t{}\t\t{}\t{docnum}\t{memo}\n",
account.path,
format_amount(signed(line.side, line.amount.amount)),
));
}
out.push_str("ENDTRNS\n");
}
out
Ok(out)
}
/// Debit positive, credit negative.
/// Debit positive, credit negative (Intuit: "Debit amounts are always
/// positive, credit amounts are always negative").
fn signed(side: Side, amount: Decimal) -> Decimal {
match side {
Side::Debit => amount,
@@ -72,24 +225,34 @@ fn signed(side: Side, amount: Decimal) -> Decimal {
}
}
/// Two decimal places, no thousands separators.
fn format_amount(d: Decimal) -> String {
// Two decimal places, no thousands separators.
format!("{d:.2}")
}
/// Strip tabs/newlines that would corrupt the tab-delimited format.
fn sanitize(s: &str) -> String {
s.replace(['\t', '\n', '\r'], " ")
/// A field IIF can carry: no tab or line break (there is no escape), and
/// ASCII only, common punctuation transliterated.
fn field(s: &str) -> String {
s.chars()
.map(|c| match c {
'\t' | '\n' | '\r' => ' ',
'\u{2013}' | '\u{2014}' | '\u{2212}' => '-',
'\u{2018}' | '\u{2019}' => '\'',
'\u{201C}' | '\u{201D}' => '"',
'\u{00A0}' => ' ',
c if c.is_ascii() && !c.is_ascii_control() => c,
_ => '?',
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::{AccountType, Currency, Ledger, Money};
use rust_decimal::Decimal;
use ledger_core::{AccountType, Currency, Money};
#[test]
fn iif_round_trip_shape() {
fn iif_declares_accounts_then_journals() {
let mut ledger = Ledger::new(Currency::USD);
let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
@@ -97,43 +260,27 @@ mod tests {
let rev = ledger
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let id = ledger.create_entry(
chrono::NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
"Sale",
);
ledger
.add_line(
id,
cash,
Side::Debit,
Money::new(Decimal::new(100000, 2), Currency::USD),
)
.unwrap();
ledger
.add_line(
id,
rev,
Side::Credit,
Money::new(Decimal::new(100000, 2), Currency::USD),
)
.unwrap();
let id = ledger.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(), "Sale");
let m = Money::new(Decimal::new(100000, 2), Currency::USD);
ledger.add_line(id, cash, Side::Debit, m).unwrap();
ledger.add_line(id, rev, Side::Credit, m).unwrap();
ledger.post_entry(id).unwrap();
let entry = ledger.get_entry(id).unwrap();
let names = |a: ledger_core::AccountId| {
ledger
.chart()
.get(a)
.map(|ac| ac.name.clone())
.unwrap_or_default()
};
let text = journal_to_iif(&[IifEntry {
entry,
account_names: &names,
}]);
assert!(text.contains("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO"));
assert!(text.contains("TRNS\tGENERAL JOURNAL\t01/15/2026\tCash"));
assert!(text.contains("\t1000.00\t"));
assert!(text.contains("SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00"));
assert!(text.contains("ENDTRNS"));
let accounts = iif_accounts(&ledger, |a| match a.account_type {
AccountType::Asset => QbAccountType::Bank,
_ => QbAccountType::Income,
});
let text = journal_to_iif(&accounts, &[ledger.get_entry(id).unwrap()]).unwrap();
assert_eq!(
text,
"!ACCNT\tNAME\tACCNTTYPE\tACCNUM\n\
ACCNT\tCash\tBANK\t1000\n\
ACCNT\tRevenue\tINC\t4000\n\
!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\n\
!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\n\
!ENDTRNS\n\
TRNS\t\tGENERAL JOURNAL\t1/5/2026\tCash\t\t1000.00\t1\tSale\n\
SPL\t\tGENERAL JOURNAL\t1/5/2026\tRevenue\t\t-1000.00\t1\tSale\n\
ENDTRNS\n"
);
}
}
+2 -2
View File
@@ -17,9 +17,9 @@ pub mod report;
pub mod templates;
pub mod xlsx;
pub use bank_csv::{BankTransaction, parse_bank_csv};
pub use bank_csv::{BankCsvError, BankTransaction, DateOrder, parse_bank_csv, parse_bank_csv_with};
pub use chart_csv::{ChartRow, parse_chart_csv};
pub use iif::{IifEntry, journal_to_iif};
pub use iif::{IifAccount, IifError, QbAccountType, iif_accounts, journal_to_iif};
pub use pdf::{PdfError, statement_pdf};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
pub use templates::{ChartTemplate, EntityType, chart_template};
+102 -34
View File
@@ -1668,14 +1668,25 @@ impl BooksServer {
#[tool(
name = "books.import_bank_csv",
description = "Import a QuickBooks-style bank/credit-card CSV (Date,Description,Amount or Date,Description,Expense,Income). Creates one DRAFT entry per transaction: bank account on one side, categorized offset on the other (via rules or default_offset_code). Review with books.digest, then post."
description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Review with books.digest, then post."
)]
async fn import_bank_csv(
&self,
params: Parameters<ImportBankCsvParams>,
) -> Result<Json<ImportBankCsvOut>, String> {
let p = params.0;
let txns = ledger_import::parse_bank_csv(&p.csv_text).map_err(|e| e.to_string())?;
let order = match p.date_order.as_deref() {
None => None,
Some("month_first") => Some(ledger_import::DateOrder::MonthFirst),
Some("day_first") => Some(ledger_import::DateOrder::DayFirst),
Some(other) => {
return Err(format!(
"date_order must be \"month_first\" or \"day_first\", got {other:?}"
));
}
};
let txns =
ledger_import::parse_bank_csv_with(&p.csv_text, order).map_err(|e| e.to_string())?;
let mut store = self.lock()?;
let bank = store
.ledger()
@@ -1700,14 +1711,41 @@ impl BooksServer {
.ok_or_else(|| format!("unknown rule account code: {code}"))?;
rules.push((pattern.to_lowercase(), (id, c)));
}
// The most specific pattern wins (longest, then alphabetical): the
// rules arrive as a map with no order, so "first match" would pick
// a different account from run to run when two patterns match.
rules.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
let offset_for = |description: &str| {
let desc_lower = description.to_lowercase();
rules
.iter()
.find(|(pat, _)| desc_lower.contains(pat.as_str()))
.map(|(_, v)| v.clone())
.unwrap_or_else(|| default_offset.clone())
};
let base = store.ledger().base_currency();
// Rehearse the whole file on a copy first, so a transaction that
// can't become a draft (a date in a closed period, say) refuses the
// file instead of leaving the ones before it behind.
let mut scratch = store.ledger().clone();
for txn in &txns {
let (offset_id, _) = offset_for(&txn.description);
let amount = Money::new(txn.amount.abs(), base);
let (bank_side, offset_side) = if txn.amount > Decimal::ZERO {
(Side::Debit, Side::Credit)
} else {
(Side::Credit, Side::Debit)
};
let e = scratch
.try_create_entry(txn.date, &txn.description)
.and_then(|e| scratch.add_line(e, bank.0, bank_side, amount).map(|()| e))
.and_then(|e| scratch.add_line(e, offset_id, offset_side, amount))
.map_err(|e| format!("{} {}: {e}", txn.date, txn.description));
e?;
}
let mut imported = Vec::new();
for txn in txns {
let desc_lower = txn.description.to_lowercase();
let (offset_id, offset_code) = rules
.iter()
.find(|(pat, _)| desc_lower.contains(pat))
.map(|(_, v)| v.clone())
.unwrap_or_else(|| default_offset.clone());
let (offset_id, offset_code) = offset_for(&txn.description);
let amount = Money::new(txn.amount.abs(), store.ledger().base_currency());
let entry_id = store
.create_entry(txn.date, &format!("Bank import — {}", txn.description))
@@ -2275,7 +2313,6 @@ impl BooksServer {
let p = params.0;
let rows = ledger_import::parse_chart_csv(&p.csv_text).map_err(|e| e.to_string())?;
let mut store = self.lock()?;
// Validate all rows first: nothing is half-imported.
let parse_type = |t: &str| match t {
"asset" => Ok(AccountType::Asset),
"liability" => Ok(AccountType::Liability),
@@ -2284,17 +2321,33 @@ impl BooksServer {
"expense" => Ok(AccountType::Expense),
_ => Err(format!("bad account type: {t}")),
};
// Rehearse the whole import on a copy of the ledger first, so a bad
// row anywhere (a duplicate within the file, a parent listed after
// its child or of another type, a blank code) refuses the file and
// nothing is half-imported. The old up-front checks covered only
// clashes with the existing chart and tax categories.
let mut scratch = store.ledger().clone();
for row in &rows {
if store.ledger().chart().find_by_code(&row.code).is_some() {
return Err(format!("duplicate account code: {}", row.code));
}
if let Some(cat) = &row.tax_category {
ledger_core::tax::check_category(
cat,
parse_type(&row.account_type)?,
self.company.tax_form,
)
let account_type = parse_type(&row.account_type)?;
let parent = match &row.parent_code {
Some(code) => Some(
scratch
.chart()
.find_by_code(code)
.map(|a| a.id)
.ok_or_else(|| format!("{}: unknown parent code {code}", row.code))?,
),
None => None,
};
let id = scratch
.add_account(&row.code, &row.name, account_type, parent, false)
.map_err(|e| format!("{}: {e}", row.code))?;
if let Some(cat) = &row.tax_category {
ledger_core::tax::check_category(cat, account_type, self.company.tax_form)
.map_err(|e| format!("{}: {e}", row.code))?;
scratch
.set_account_tax(id, Some(cat), None)
.map_err(|e| format!("{}: {e}", row.code))?;
}
}
let mut created = Vec::new();
@@ -2551,26 +2604,41 @@ impl BooksServer {
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
}
/// Render the journal as IIF text via ledger-import.
/// Render the journal as IIF text via ledger-import, declaring every
/// account with its QuickBooks type: the `[books] cash_accounts` are
/// BANK, the receivable and payable control accounts AR and AP, a
/// registered asset's cost and accumulated depreciation FIXASSET, and
/// the rest follow their wanderbooks type (other current asset, other
/// current liability, equity, income, expense).
fn export_iif(&self) -> Result<String, String> {
use ledger_import::{IifEntry, journal_to_iif};
use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif};
let store = self.lock()?;
let ledger = store.ledger();
let account_names = |id: ledger_core::AccountId| {
ledger
.chart()
.get(id)
.map(|a| a.name.clone())
.unwrap_or_default()
};
let entries: Vec<IifEntry<'_>> = ledger
.posted_entries()
.map(|e| IifEntry {
entry: e,
account_names: &account_names,
})
let fixed: std::collections::HashSet<AccountId> = ledger
.registered_assets()
.flat_map(|a| [a.cost_account, a.accum_depr_account])
.collect();
Ok(journal_to_iif(&entries))
let accounts = iif_accounts(ledger, |a| {
if self.roles.cash.contains(&a.code) {
Qb::Bank
} else if a.code == ledger_core::RECEIVABLES.code {
Qb::AccountsReceivable
} else if a.code == ledger_core::PAYABLES.code {
Qb::AccountsPayable
} else if fixed.contains(&a.id) {
Qb::FixedAsset
} else {
match a.account_type {
AccountType::Asset => Qb::OtherCurrentAsset,
AccountType::Liability => Qb::OtherCurrentLiability,
AccountType::Equity => Qb::Equity,
AccountType::Revenue => Qb::Income,
AccountType::Expense => Qb::Expense,
}
}
});
let entries: Vec<_> = ledger.posted_entries().collect();
journal_to_iif(&accounts, &entries).map_err(|e| e.to_string())
}
// ------------------------------------------------------------------
+9 -4
View File
@@ -681,18 +681,23 @@ pub struct CashFlowOut {
/// Parameters for `books.import_bank_csv`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ImportBankCsvParams {
/// The CSV text (QBO bank upload layout: Date,Description,Amount or
/// Date,Description,Expense,Income).
/// The CSV text (QBO bank upload layout: Date,Description,Amount, or
/// Date,Description, money out, money in).
pub csv_text: String,
/// Account code for the bank account (the offset side of each entry).
pub bank_account_code: String,
/// Account code for the default offset (e.g. an expense account).
/// Used when no rule matches.
pub default_offset_code: String,
/// Optional categorization rules: { "pattern": "account_code" }.
/// First case-insensitive substring match on the description wins.
/// Optional categorization rules: { "pattern": "account_code" }. A
/// pattern matches a description containing it (ignoring case); the
/// longest matching pattern wins.
#[serde(default)]
pub rules: std::collections::HashMap<String, String>,
/// How to read slash dates the file doesn't settle itself:
/// "month_first" (mm/dd/yyyy, US banks) or "day_first" (dd/mm/yyyy).
#[serde(default)]
pub date_order: Option<String>,
}
/// One imported bank transaction (as a draft entry).
+117
View File
@@ -1783,3 +1783,120 @@ fn cash_flow_reads_its_account_roles_from_the_books_settings() {
assert_eq!(amounts("investing"), ["-500.00"]);
assert_eq!(cf["net_change_in_cash"], "400.00");
}
/// A chart CSV with a bad row anywhere is refused whole: nothing is
/// half-imported (the old checks missed duplicates within the file and a
/// parent listed after its child).
#[test]
fn a_chart_file_with_a_bad_row_imports_nothing() {
let dir = test_dir("chart-atomic");
let mut mcp = Mcp::spawn(&dir);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
let count = |mcp: &mut Mcp| {
mcp.call("books.chart", json!({}))["accounts"]
.as_array()
.unwrap()
.len()
};
let before = count(&mut mcp);
for bad in [
// A duplicate code within the file.
"code,name,type,parent_code\n1000,Cash,asset,\n1010,Savings,asset,\n1000,Again,asset,\n",
// A parent listed after its child.
"code,name,type,parent_code\n1000,Cash,asset,\n1011,Sub,asset,1010\n1010,Bank,asset,\n",
// A child of another type than its parent.
"code,name,type,parent_code\n1000,Cash,asset,\n4000,Sales,revenue,1000\n",
] {
let err = mcp.call_err("books.import_chart_csv", json!({"csv_text": bad}));
assert!(!err.is_empty());
assert_eq!(count(&mut mcp), before, "half-imported: {bad}");
}
let ok = mcp.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n"}),
);
assert_eq!(ok["count"], 2);
}
/// Bank CSV import: slash dates that read either way need the caller's
/// order; overlapping rules pick the longest pattern every time; and a
/// transaction that can't become a draft refuses the whole file.
#[test]
fn bank_import_reads_dates_rules_and_files_whole() {
let dir = test_dir("bank-import");
let mut mcp = Mcp::spawn(&dir);
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
for (code, name, typ) in [
("1000", "Bank", "asset"),
("6000", "Other", "expense"),
("6100", "Coffee", "expense"),
("6200", "Coffee beans", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
let base = |csv: &str| {
json!({
"csv_text": csv,
"bank_account_code": "1000",
"default_offset_code": "6000",
"rules": {"coffee": "6100", "coffee beans": "6200"},
})
};
// 3/4/2026 reads both ways and nothing in the file settles it.
let ambiguous = "Date,Description,Amount\n3/4/2026,Coffee shop,-4.50\n";
let err = mcp.call_err("books.import_bank_csv", base(ambiguous));
assert!(err.contains("either way"), "{err}");
let mut args = base(ambiguous);
args["date_order"] = json!("day_first");
let out = mcp.call("books.import_bank_csv", args);
assert_eq!(out["imported"][0]["date"], "2026-04-03");
// Both patterns match "Coffee beans": the longer wins, every time.
for _ in 0..5 {
let out = mcp.call(
"books.import_bank_csv",
base("Date,Description,Amount\n2026-05-01,Coffee beans 2kg,-30.00\n"),
);
assert_eq!(out["imported"][0]["offset_account"], "6200");
}
// A transaction dated in a closed period refuses the file: nothing of
// it becomes a draft.
let p = mcp.call(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
);
mcp.call(
"books.close_period",
json!({"period_id": p["period_id"], "confirmed": true}),
);
let err = mcp.call_err(
"books.import_bank_csv",
base(
"Date,Description,Amount\n2026-06-01,Fine,-1.00\n2026-01-15,In a closed month,-2.00\n",
),
);
assert!(err.contains("closed"), "{err}");
let journal = std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
assert!(
!journal.contains("2026-06-01"),
"the first row was journaled"
);
}