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"
);
}
+2 -1
View File
@@ -13,7 +13,8 @@ layers differ in what they cover and in whether we may copy them:
| Layer | What it governs | Copyright | Here |
|---|---|---|---|
| **Pacioli (1494), Geijsbeek translation (1914)** | The mechanics of double entry: debits equal credits, the trial balance, one money, writing only forward, correcting by opposite entry, closing into capital. No modern standard restates these; they predate every regulator. | Public domain | Vendored verbatim: `sources/pacioli-1494.md` |
| **IRS: Internal Revenue Code, Treasury Regulations, publications, form instructions** | Records and supporting documents, retention, accounting periods and methods, information returns (1099), capitalization (de minimis, 12-month rule), partners' capital, payroll taxes, rounding on returns | Public domain (17 U.S.C. §105; IRS.gov's content policy) | Vendored verbatim: `sources/irs-*.md`, `sources/us-public-domain-status.md` |
| **IRS: Internal Revenue Code, Treasury Regulations, publications, form instructions** | Records and supporting documents, retention, accounting periods and methods, information returns (1099), capitalization (de minimis, 12-month rule), partners' capital, payroll taxes, rounding on returns, return line items for tax categories (`sources/irs-tax-lines.md`) | Public domain (17 U.S.C. §105; IRS.gov's content policy) | Vendored verbatim: `sources/irs-*.md`, `sources/us-public-domain-status.md` |
| **File formats: Intuit (QuickBooks Online bank CSV, QuickBooks Desktop IIF), ECMA-376 Office Open XML, Microsoft Excel limits** | What `ledger-import` reads and writes: bank/card CSV layouts and signs, IIF journal entries, how XLSX stores numbers and Excel's 15-digit precision | © Intuit Inc.; © Ecma International; © Microsoft | Cited with short quotes, URLs, and hashes: `sources/intuit-bank-csv.md`, `sources/intuit-iif.md`, `sources/xlsx-numbers.md` |
| **FASB Accounting Standards Codification (ASC)** | Authoritative US GAAP: cash flows (ASC 230), revenue and principal vs agent (ASC 606), foreign currency (ASC 830), credit losses and aging (ASC 326), property and depreciation (ASC 360) | © Financial Accounting Foundation | Cited by paragraph with short quotes only (in `rules.md`) |
| **FASB Concepts Statement No. 8, Chapter 4 (2021)** | Definitions of the five elements (it replaced Concepts Statement 6) | © Financial Accounting Foundation; non-authoritative | Cited with short quotes only |
| **Textbooks (OpenStax *Principles of Accounting, Vol. 1*)** | Conventions no standard states: normal balances, closing-entry mechanics, straight-line depreciation arithmetic | CC BY-NC-SA 4.0 (incompatible with this repo's license) | Cited with short quotes and attribution only |
+88
View File
@@ -0,0 +1,88 @@
# QuickBooks Online bank and credit-card CSV upload format
Governs `ledger_import::bank_csv` (the QBO-style bank/card CSV parser). Intuit's help articles are © Intuit Inc., not public domain, so per the vendoring rules (docs/spec/README.md) they are cited, with short quotes only; everything not inside a `>` blockquote is our description of the page, not Intuit's words. Intuit states two layouts: 3 columns (Date, Description, Amount) and 4 columns (Date, Description, Credit, Debit). The pages were read in a browser; the hashes are of the HTML the browser fetched at retrieval (a dynamic page, so a later fetch will differ).
## Format CSV files in Excel to get bank transactions into QuickBooks (US, updated 8/24/2026)
Source: <https://quickbooks.intuit.com/learn-support/en-us/help-article/bank-transactions/format-csv-files-excel-bank-transactions-quickbooks/L4BjLWckq_US_en_US>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`L4BjLWckq_US_en_US.html`, 533,442 bytes): `cbdd8c42ddda944b639db450b0a5c7986c17516c7e699612bea24b950e115bd0`
**Layouts**
> You can upload CSVs with either the 3-column or 4-column format to QuickBooks.
Cited (not quoted): the 3-column layout's columns are Date, Description, Amount; the 4-column layout's columns are Date, Description, Credit, Debit, in that order.
**Sign and column meaning (from Intuit's example tables, transcribed cell by cell)**
3-column example (header row, then two rows):
| Date | Description | Amount |
|---|---|---|
| 1/1/2018 | Example of a payment | -100.00 |
| 1/1/2018 | Example of a deposit | 200.00 |
4-column example:
| Date | Description | Credit | Debit |
|---|---|---|---|
| 1/1/2018 | Example of a payment | 100.00 | |
| 1/1/2018 | Example of a deposit | | 200.00 |
Cited (not quoted): in the 3-column layout a payment (money out) is negative and a deposit (money in) positive. In the 4-column layout Intuit's example puts the payment in the **Credit** column and the deposit in the **Debit** column, each amount positive, the other cell empty. The page has no sentence stating which of Credit/Debit means money in; the example is the only evidence.
**Header rules**
Cited (not quoted): remove the word "amount" from Credit/Debit headers, so they read only "Credit" and "Debit". The page does not say whether a header row is required or optional; both examples have one.
**Zeros**
> Remove any zeros (0) from the file. Leave those cells blank.
**Description column**
Cited (not quoted): correct transactions whose Description cell shows numbers.
**Dates**
Cited (not quoted): keep every date in one format; Intuit recommends dd/mm/yyyy (its own examples are written 1/1/2018, which reads the same either way). A day-of-week suffix in the date cell (its example: 20/11/2018 TUE) must be split off and removed. No list of accepted date formats is given.
**Credit card files**
Cited (not quoted): card CSVs may show signs reversed relative to a bank account (payments to the card can appear as negative deposits); upload them into the credit card account, not a bank account.
**Platform note**
Cited (not quoted): Mac users must save as a Windows CSV file.
## Manually upload transactions into QuickBooks Online (US, updated 8/24/2026)
Source: <https://quickbooks.intuit.com/learn-support/en-us/help-article/import-transactions/manually-upload-transactions-quickbooks-online/L0rE9OXBz_US_en_US>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`L0rE9OXBz_US_en_US.html`): `b32d52c6efe6e4e6dced48876cc93b351ac66168bf2f80e0bedef27b4b4337d7`
**File limits and columns**
> Your upload file must be in English and 350 KB or less.
Cited (not quoted): each file needs either 3 columns (Date, Description, Amount) or 4 (Date, Description, Credit, Debit); cells in Amount, Credit, or Debit holding only zero are left blank; dates in one format, dd/mm/yyyy recommended. During upload the user matches the file's columns to QuickBooks fields on screen (so QBO does not rely on header text or position alone).
## Fix bank upload errors (US, updated 8/4/2026)
Source: <https://quickbooks.intuit.com/learn-support/en-us/help-article/bank-transactions/fix-bank-upload-errors-quickbooks-online/L6FBh3zDC_US_en_US>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`L6FBh3zDC_US_en_US.html`): `d730b312cd5b0583f9e6947b30e4bca0a9be8f5935363c6e3f96d264d19f784c`
**Amount size limit**
Cited (not quoted): the error "The uploaded file contains invalid transaction amount information." occurs when a transaction has 12 or more digits; QuickBooks cannot upload amounts that large.
## Not found in any Intuit source
Searched 2026-10-13 (the three US articles above; Intuit's other regional editions were not read); none states:
- whether thousands separators (`1,234.56`) are accepted in amount cells;
- whether currency symbols (`$`) are accepted;
- whether parentheses for negatives (`(100.00)`) are accepted;
- whether the 4-column order may be Debit before Credit, or other header names (Expense/Income, Withdrawal/Deposit) are accepted;
- an exhaustive list of accepted date formats, or how QBO disambiguates `1/2/2018`;
- whether a header row is mandatory.
Rules wanderbooks applies on these points are CONVENTION or DECISION, not Intuit's.
+118
View File
@@ -0,0 +1,118 @@
# QuickBooks Desktop IIF (Intuit Interchange Format): general journal entries
Governs `ledger_import::iif` (IIF export of posted entries for QuickBooks Desktop). Intuit's help pages and IIF Import Kit are © Intuit Inc., not public domain, so per the vendoring rules (docs/spec/README.md) they are cited, with short quotes only; everything not inside a `>` blockquote is our description, not Intuit's words. The most authoritative source found is Intuit's own IIF Import Kit (header reference "IIF Header Help", the "IIF Import Manual" PDF updated 9/30/2008, and per-version example files), linked from the current help article below.
## IIF Overview: import kit, sample files, and headers (US, updated 8/4/2026)
Source: <https://quickbooks.intuit.com/learn-support/en-us/help-article/list-management/iif-overview-import-kit-sample-files-headers/L5CZIpJne_US_en_US>
Retrieved: 2026-10-13
SHA-256: not available (read in a browser; the page's fetch included session query data and was not saved)
**What an IIF file is**
Cited (not quoted): IIF files are ASCII text, tab-separated values, used by QuickBooks Desktop to import or export lists or transactions. Header rows begin with an exclamation point and their first column holds a keyword. A transaction import file usually has three header rows, `!TRNS`, `!SPL`, `!ENDTRNS`.
**Intuit's worked example (a check), transcribed cell by cell**
| A | B | C | D | E | F | G |
|---|---|---|---|---|---|---|
| !TRNS | TRNSTYPE | DATE | ACCNT | NAME | CLASS | AMOUNT |
| !SPL | TRNSTYPE | DATE | ACCNT | NAME | CLASS | AMOUNT |
| !ENDTRNS | | | | | | |
| TRNS | CHECK | 3/3/2010 | Main Checking | ArchCo Gas | | -70 |
| SPL | CHECK | 3/3/2010 | Fuel | Courtney, Ed | | 10 |
| SPL | CHECK | 3/3/2010 | Auto Repair | | | 60 |
| ENDTRNS | | | | | | |
Cited (not quoted): the bank account (credited) is -70 and the two expense splits (debited) are +10 and +60, so TRNS plus SPL amounts sum to zero. Not every header is needed for a transaction to import.
**Unknown accounts**
> If you import a transaction with an account that is not already created in the QuickBooks data file, that account will be created as a new bank account.
Cited (not quoted): so every account named in the file must exist in QuickBooks before import. IIF import does only limited error checking; a malformed file can import incorrectly with no error. Intuit gives no assisted support for creating IIF files.
## Export, import, and edit IIF files (US, updated 8/4/2026)
Source: <https://quickbooks.intuit.com/learn-support/en-us/help-article/import-export-data-files/export-import-edit-iif-files/L56LT9Z0Q_US_en_US>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`L56LT9Z0Q_US_en_US.html`): `a52d5ccb79014765831421f73dd13c64c85c6bd98a56d3e47234edd93af941ab`
Cited (not quoted): general journal entries are among the importable transaction types; transactions can be imported but not exported; imported transactions are not linked to each other. Import is File > Utilities > Import > IIF Files.
## IIF Import Kit (`IIF_Import_Kit.zip`)
Source: <https://support.quickbooks.intuit.com/support/pages/executable/iif/iif_import_kit.zip> (redirects to <https://http-download.intuit.com/http.intuit/OpenCms/sites/default/QBSupportSite/executables/IIF/IIF_Import_Kit.zip>)
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`IIF_Import_Kit.zip`): `45dde93a8bec83569820a63c50bb2ca0477e10380c358b4838e87c74382f0834`
### IIF Header Help: Transactions (TRNS and SPL)
SHA-256 of `IIF Import Kit/IIF Header Help/!TRNS.htm`: `a303221fb779a6d9383e6f3a5730e44f40e16fcd04ae1cc122dad142ec37d499`
**Structure**
Cited (not quoted): the transaction keyword is TRNS and each distribution line's is SPL; the SPL lines follow the TRNS line immediately, and ENDTRNS follows the last SPL. TRNSTYPE, ACCNT, and AMOUNT are the required TRNS columns. `GENERAL JOURNAL` is the TRNSTYPE keyword for general journal transactions.
**AMOUNT sign**
> Debit amounts are always positive, credit amounts are always negative.
**Other TRNS columns (cited, not quoted)**
- `TRNSID`, `TIMESTAMP`: identifiers.
- `DATE`: the help says the date is always in MM/DD/YY format, with the example 1/30/94. (Intuit's own general journal example file, below, uses a four-digit year: 7/1/1998.)
- `ACCNT` (required): the account's name.
- `NAME`: customer, vendor, payee, or employee.
- `CLASS`: class name; a subclass is written with its parents, highest first, separated by colons.
- `DOCNUM`: the transaction's number.
- `MEMO`: memo text.
- `CLEAR`: `Y` cleared, `N` not cleared.
- `TOPRINT`, `ADDR1`–`ADDR5`, `DUEDATE`, `TERMS`, `PAID`, `PAYMETH`, `SHIPVIA`, `SHIPDATE`, `REP`, `FOB`, `PONUM`, `INVTITLE`, `INVMEMO`, `SADDR1`–`SADDR5`: document-specific fields not used by a general journal entry.
### IIF Header Help: distribution lines (SPL)
SHA-256 of `IIF Import Kit/IIF Header Help/!SPL.htm`: `87cc94da55a7291b76402bb505677f4cd5e3317e1c7602fcaca81f56b2fba4c1`
Cited (not quoted): `SPLID` is marked required (a unique number for the line within the transaction); `TRNSTYPE` and `DATE` on SPL must match the TRNS line's; `ACCNT` is required; `AMOUNT` is required and credit amounts are negative; `NAME`, `CLASS`, `DOCNUM`, `MEMO`, `CLEAR` as on TRNS. (Intuit's own general journal example file leaves the SPLID column empty, below.)
### IIF Header Help: Chart of accounts (ACCNT)
SHA-256 of `IIF Import Kit/IIF Header Help/!ACCNT.htm`: `91b5cbcc90b3b251b467448123945b5388717095aed0e75a1a14847e1469262e`
**Subaccount names**
> If you are creating an import file, use a colon (:) to separate subaccount names.
Cited (not quoted): a subaccount's NAME includes its parents' names, highest level first. Accounts are identified by NAME; the account number (`ACCNUM`) is a separate, optional column.
### IIF Example Files: QB 2007 & Enterprise 7.0 and later, General Journal Entry
SHA-256 of `IIF Import Kit/IIF Example Files/QB 2007 & Enterprise 7.0 and later/Journal Entry/2007 General Journal Entry.iif`: `78917e0f848f38b55f0f1bbde16ef19b9144132a2b21f11831ff29b2ba549879`
The file's records, transcribed (each `→` is one tab character; every line ends CR LF in the file):
```text
!TRNS→TRNSID→TRNSTYPE→DATE→ACCNT→CLASS→AMOUNT→DOCNUM→MEMO
!SPL→SPLID→TRNSTYPE→DATE→ACCNT→CLASS→AMOUNT→DOCNUM→MEMO
!ENDTRNS→→→→→→→→
TRNS→→GENERAL JOURNAL→7/1/1998→Checking→→650→→
SPL→→GENERAL JOURNAL→7/1/1998→Expense Account→→-650→→
ENDTRNS→→→→→→→→
```
Cited (not quoted): debit +650, credit -650, sum zero; TRNSID and SPLID left empty; DATE is M/D/YYYY without leading zeros.
### IIF Import Manual ("Start Here - IIF Import Manual.pdf", updated 9/30/2008)
SHA-256 of `IIF Import Kit/Start Here - IIF Import Manual.pdf`: `e8f4185ac61194e1e18a8f5a61f1ea47d1d073efd815f8127e3fc77682c744f6`
Cited (not quoted): IIF is tab-delimited, usually edited in a spreadsheet; the format is unforgiving of syntax errors; nothing stops the same file being imported twice (duplicating every transaction) and there is no undo; list elements (accounts, names) must already exist or be declared at the top of the file before the transactions, or default types are assumed; an error's line number is the spreadsheet row, usually the ENDTRNS that tried to commit the transaction; the file format depends on the QuickBooks product year.
## General Journal sample (`journal.zip`)
Source: <https://support.quickbooks.intuit.com/support/pages/executable/iif/journal.zip> (redirects to <https://http-download.intuit.com/http.intuit/OpenCms/sites/default/QBSupportSite/executables/IIF/journal.zip>)
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`journal.zip`): `4f578d227810f5bfadde142cdab9f941f96de3d2c8c201da8d61126799e9f500`
Cited (not quoted): its read-me says list declarations (accounts, classes, customers, vendors, other names) come first so names land on the correct lists, and that header columns must be present even when left blank. Its `!ACCNT` rows show a subaccount written `Construction:Labor`. Its address fields that contain a comma are wrapped in double quotes (`"Anywhere, AZ 85740"`), as a spreadsheet writes tab-delimited text; the kit does not say whether QuickBooks strips those quotes.
## Not found in any Intuit source
Searched 2026-10-13 (the help articles above and every document in the IIF Import Kit and journal sample); none states:
- an escaping mechanism for tabs, line breaks, or double quotes inside a field (no escape syntax is documented; the only safe reading is that fields must not contain tabs or line breaks);
- an explicit sentence that a transaction's TRNS and SPL amounts must sum to zero (every example does; the sign rule implies it);
- the allowed number of decimal places, or whether thousands separators or currency symbols are accepted in AMOUNT;
- the character encoding beyond "ASCII text";
- whether a four-digit year is accepted everywhere despite the "MM/DD/YY" wording (Intuit's 2007+ example uses one).
+372
View File
@@ -0,0 +1,372 @@
# Tax return line items: Schedule C, Form 1065, Form 1120-S, Form 1120
Governs `tax::CATEGORIES` (crates/ledger-core/src/tax.rs) and the tax categories on the starter charts (crates/ledger-import/src/templates.rs). The forms quoted are the 2025 revisions (filed in 2026), the current ones on irs.gov at retrieval. Line numbers move between years; the categories are stable keys, and the mapping at the end must be re-checked against each new revision. Captions are copied from the form's page 1 with dot leaders and the right-hand line-number boxes removed (whitespace normalized); a two-column form layout is given one caption per line.
## Schedule C (Form 1040) 2025, Profit or Loss From Business
Source: <https://www.irs.gov/pub/irs-pdf/f1040sc.pdf>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`f1040sc.pdf`): `ddf401dbe060467d39f90ad2abf645df1de31512821a150dc68a3882bbf19716`
**Part I, Income**
> 1 Gross receipts or sales. See instructions for line 1 and check the box if this income was reported to you on Form W-2 and the “Statutory employee” box on that form was checked
>
> 2 Returns and allowances
>
> 3 Subtract line 2 from line 1
>
> 4 Cost of goods sold (from line 42)
>
> 5 Gross profit. Subtract line 4 from line 3
>
> 6 Other income, including federal and state gasoline or fuel tax credit or refund (see instructions)
>
> 7 Gross income. Add lines 5 and 6
**Part II, Expenses**
> Part II Expenses. Enter expenses for business use of your home only on line 30.
>
> 8 Advertising
>
> 9 Car and truck expenses (see instructions)
>
> 10 Commissions and fees
>
> 11 Contract labor (see instructions)
>
> 12 Depletion
>
> 13 Depreciation and section 179 expense deduction (not included in Part III) (see instructions)
>
> 14 Employee benefit programs (other than on line 19)
>
> 15 Insurance (other than health)
>
> 16 Interest (see instructions): a Mortgage (paid to banks, etc.) b Other
>
> 17 Legal and professional services
>
> 18 Office expense (see instructions)
>
> 19 Pension and profit-sharing plans
>
> 20 Rent or lease (see instructions): a Vehicles, machinery, and equipment b Other business property
>
> 21 Repairs and maintenance
>
> 22 Supplies (not included in Part III)
>
> 23 Taxes and licenses
>
> 24 Travel and meals: a Travel b Deductible meals (see instructions)
>
> 25 Utilities
>
> 26 Wages (less employment credits)
>
> 27a Energy efficient commercial bldgs deduction (attach Form 7205)
>
> b Other expenses (from line 48)
>
> 28 Total expenses before expenses for business use of home. Add lines 8 through 27b
## 2025 Instructions for Schedule C
Source: <https://www.irs.gov/pub/irs-pdf/i1040sc.pdf>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`i1040sc.pdf`): `b5536db91498a450572e37078d7d5ee8fdc400e385d4fee3a26c230a33e399b6`
**Line 6 (other income), in part**
> Report on line 6 business income not reported elsewhere in Part I. Be sure to include amounts from the following. • Finance reserve income. • Scrap sales. • Bad debts you recovered. • Interest (such as on notes and accounts receivable).
**Line 24b (meals), in part**
> Enter your deductible business meal expenses. This includes expenses for meals while traveling away from home for business. Your deductible business meal expenses are a percentage of your actual business meal expenses or standard meal allowance. See Amount of deduction, later, for the percentage that applies to your actual meal expenses or standard meal allowance. In most cases, the percentage is 50%.
**Part V. Other Expenses. Line 48**
> Include all ordinary and necessary business expenses not deducted elsewhere on Schedule C. List the type and amount of each expense separately in the space provided. Enter the total on lines 48 and 27b. Do not include the cost of business equipment or furniture; replacements or permanent improvements to property; or personal, living, and family expenses. Do not include charitable contributions. Also, you can’t deduct fines or penalties paid to a government for violating any law.
**Part V: bad debts**
> Bad debts. Include debts and partial debts from sales or services that were included in income and are definitely known to be worthless. If you later collect a debt that you deducted as a bad debt, include it as income in the year collected.
**Part V: business startup costs**
> Business startup costs. If your business began in 2025, you can elect to deduct up to $5,000 of certain business startup costs. The $5,000 limit is reduced (but not below zero) by the amount by which your total startup costs exceed $50,000. Your remaining startup costs can be amortized over a 180-month period beginning with the month the business began.
## Form 1065 (2025), U.S. Return of Partnership Income, page 1
Source: <https://www.irs.gov/pub/irs-pdf/f1065.pdf>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`f1065.pdf`): `0f19f556e12ef53c41ba27e5930b4373103f3abcf64693c4ea686451b2a8f56f`
**Income**
> Caution: Include only trade or business income and expenses on lines 1a through 23 below. See instructions for more information.
>
> 1a Gross receipts or sales b Less returns and allowances c Balance
>
> 2 Cost of goods sold (attach Form 1125-A)
>
> 3 Gross profit. Subtract line 2 from line 1c
>
> 4 Ordinary income (loss) from other partnerships, estates, and trusts (attach statement)
>
> 5 Net farm profit (loss) (attach Schedule F (Form 1040))
>
> 6 Net gain (loss) from Form 4797, Part II, line 17 (attach Form 4797)
>
> 7 Other income (loss) (attach statement)
>
> 8 Total income (loss). Combine lines 3 through 7
**Deductions (see instructions for limitations)**
> 9 Salaries and wages (other than to partners) (less employment credits)
>
> 10 Guaranteed payments to partners
>
> 11 Repairs and maintenance
>
> 12 Bad debts
>
> 13 Rent
>
> 14 Taxes and licenses
>
> 15 Interest (see instructions)
>
> 16a Depreciation (if required, attach Form 4562)
>
> b Less depreciation reported on Form 1125-A and elsewhere on return
>
> 17 Depletion (Do not deduct oil and gas depletion.)
>
> 18 Retirement plans, etc.
>
> 19 Employee benefit programs
>
> 20 Energy efficient commercial buildings deduction (attach Form 7205)
>
> 21 Other deductions (attach statement)
>
> 22 Total deductions. Add the amounts shown in the far right column for lines 9 through 21
>
> 23 Ordinary business income (loss). Subtract line 22 from line 8
## Instructions for Form 1065 (2025)
Source: <https://www.irs.gov/pub/irs-pdf/i1065.pdf>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`i1065.pdf`): `f4a35a24c7abac486908d3610754397a66b2130f0eb425dda897d78a9d91b786`
**Separately stated items (charitable contributions), in part**
> Items the partnership must state separately that require separate computations by the partners. Examples include expenses incurred for the production of income instead of in a trade or business, charitable contributions, foreign taxes paid or accrued, intangible drilling and development costs, soil and water conservation expenditures, amortizable basis of reforestation expenditures, and exploration expenditures.
**Line 10. Guaranteed Payments to Partners, in part**
> Deduct payments or credits to a partner for services or for the use of capital if the payments or credits are determined without regard to partnership income and are allocable to a trade or business activity.
**Business startup and organizational costs, in part**
> Business startup and organizational costs. Generally, a partnership can elect to deduct a limited amount of startup or organizational costs paid or incurred. Any costs not deducted must be amortized as explained below. See sections 195(b) and 709(b).
> Amortization. Any costs not deducted under the above rules must be amortized ratably over a 180-month period, beginning with the month the partnership begins business. See the Instructions for Form 4562 for details.
**Line 21. Other Deductions**
> Enter the total allowable trade or business deductions that aren’t deductible elsewhere on page 1 of Form 1065. Attach a statement listing by type and amount each deduction included on this line. Examples of other deductions include the following. • Amortization. See the Instructions for Form 4562 for more information. Complete and attach Form 4562 if the partnership is claiming amortization of costs that began during the tax year. • Insurance premiums. • Legal and professional fees. • Supplies used and consumed in the business. • Utilities. • Certain business startup and organizational costs. See Limitations on Deductions, earlier, for more details. • Any net negative section 481(a) adjustment.
## Form 1120-S (2025), U.S. Income Tax Return for an S Corporation, page 1
Source: <https://www.irs.gov/pub/irs-pdf/f1120s.pdf>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`f1120s.pdf`): `c4b7cae7f2283a2e3225523ea001a710109fee34aad909461421025b287cd18d`
**Income**
> Caution: Include only trade or business income and expenses on lines 1a through 22. See the instructions for more information.
>
> 1a Gross receipts or sales b Less returns and allowances c Balance
>
> 2 Cost of goods sold (attach Form 1125-A)
>
> 3 Gross profit. Subtract line 2 from line 1c
>
> 4 Net gain (loss) from Form 4797, Part II, line 17 (attach Form 4797)
>
> 5 Other income (loss) (see instructions—attach statement)
>
> 6 Total income (loss). Add lines 3 through 5
**Deductions**
> 7 Compensation of officers (see instructions—attach Form 1125-E)
>
> 8 Salaries and wages (less employment credits)
>
> 9 Repairs and maintenance
>
> 10 Bad debts
>
> 11 Rents
>
> 12 Taxes and licenses
>
> 13 Interest (see instructions)
>
> 14 Depreciation from Form 4562 not claimed on Form 1125-A or elsewhere on return (attach Form 4562)
>
> 15 Depletion (do not deduct oil and gas depletion)
>
> 16 Advertising
>
> 17 Pension, profit-sharing, etc., plans
>
> 18 Employee benefit programs
>
> 19 Energy efficient commercial buildings deduction (attach Form 7205)
>
> 20 Other deductions (attach statement)
>
> 21 Total deductions. Add lines 7 through 20
>
> 22 Ordinary business income (loss). Subtract line 21 from line 6
## Instructions for Form 1120-S (2025)
Source: <https://www.irs.gov/pub/irs-pdf/i1120s.pdf>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`i1120s.pdf`): `eab655cff9226dbc6d80213350bac050009ba90daf621c182d31c541bc62c3f2`
**Start-up and organizational costs, in part**
> Report the deductible amount of start-up and organizational costs and any amortization on line 20. For amortization that begins during the current tax year, complete and attach Form 4562, Depreciation and Amortization.
**Line 20. Other Deductions, in part**
> Enter the total allowable trade or business deductions that aren’t deductible elsewhere on Form 1120-S, page 1. Attach a statement listing by type and amount each deduction included on this line. Examples of other deductions include the following. • Amortization. See Form 4562, Part VI. • Certain business start-up and organizational costs (discussed earlier). • Insurance premiums. • Legal and professional fees. • Supplies used and consumed in the business. • Travel, meal, and entertainment expenses. Special rules apply (discussed later). • Utilities.
## Form 1120 (2025), U.S. Corporation Income Tax Return, page 1
Source: <https://www.irs.gov/pub/irs-pdf/f1120.pdf>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`f1120.pdf`): `016c7fb4042a565c23a98014b17019af7ddc89e3260dd3f926b84d6688b7be6e`
**Income**
> 1a Gross receipts or sales
>
> b Returns and allowances
>
> c Balance. Subtract line 1b from line 1a
>
> 2 Cost of goods sold (attach Form 1125-A)
>
> 3 Gross profit. Subtract line 2 from line 1c
>
> 4 Dividends and inclusions (Schedule C, line 23)
>
> 5 Interest
>
> 6 Gross rents
>
> 7 Gross royalties
>
> 8 Capital gain net income (attach Schedule D (Form 1120))
>
> 9 Net gain or (loss) from Form 4797, Part II, line 17 (attach Form 4797)
>
> 10 Other income (see instructions—attach statement)
>
> 11 Total income. Add lines 3 through 10
**Deductions**
> 12 Compensation of officers (see instructions—attach Form 1125-E)
>
> 13 Salaries and wages (less employment credits)
>
> 14 Repairs and maintenance
>
> 15 Bad debts
>
> 16 Rents
>
> 17 Taxes and licenses
>
> 18 Interest (see instructions)
>
> 19 Charitable contributions
>
> 20 Depreciation from Form 4562 not claimed on Form 1125-A or elsewhere on return (attach Form 4562)
>
> 21 Depletion
>
> 22 Advertising
>
> 23 Pension, profit-sharing, etc., plans
>
> 24 Employee benefit programs
>
> 25 Energy efficient commercial buildings deduction (attach Form 7205)
>
> 26 Other deductions (attach statement)
>
> 27 Total deductions. Add lines 12 through 26
>
> 28 Taxable income before net operating loss deduction and special deductions. Subtract line 27 from line 11.
## Instructions for Form 1120 (2025)
Source: <https://www.irs.gov/pub/irs-pdf/i1120.pdf>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`i1120.pdf`): `49476a6f69a38a0fdb96fea1d66ddc936404e9b2eebb249da8e20830b0743358`
**Start-up and organizational costs, in part**
> Report the deductible amount of start-up and organizational costs and any amortization on line 26.
---
## Mapping (wanderbooks, derived)
**This section is derived, not quoted.** It is wanderbooks' reading of the 2025 forms and instructions above, written 2026-10-13, for the categories in `crates/ledger-core/src/tax.rs`. It is not tax advice; the CPA decides the return. "—" means the category does not apply to that form (per `TaxCategory::forms`). "Not page 1" means the quoted page-1 lines have no place for it; where it goes instead (for example Schedule K) is not quoted in this file and is left to the CPA.
| Category key | Schedule C | Form 1065 | Form 1120-S | Form 1120 |
|---|---|---|---|---|
| `gross_receipts` | 1 | 1a | 1a | 1a |
| `returns_allowances` | 2 | 1b | 1b | 1b |
| `interest_income` | 6 for interest on notes and accounts receivable (instructions, line 6); other interest not determined here | Not page 1 (separately stated; not quoted) | Not page 1 (not quoted) | 5 |
| `other_income` | 6 | 7 | 5 | 10 |
| `cost_of_goods_sold` | 4 (from Part III, line 42) | 2 | 2 | 2 |
| `advertising` | 8 | 21 (no advertising line) | 16 | 22 |
| `bad_debts` | 27b via Part V (no line in Part II; instructions list bad debts under Part V) | 12 | 10 | 15 |
| `car_truck` | 9 | 21 | 20 | 26 |
| `charitable_contributions` | Not deductible on Schedule C (Part V: "Do not include charitable contributions") | Not page 1: separately stated (instructions) | Not page 1 (not quoted) | 19 |
| `commissions_fees` | 10 | 21 | 20 | 26 |
| `contract_labor` | 11 | 21 | 20 | 26 |
| `depreciation` | 13 (tax amount, not book; CPA) | 16a/16c (tax amount; CPA) | 14 (tax amount; CPA) | 20 (tax amount; CPA) |
| `employee_benefits` | 14 | 19 | 18 | 24 |
| `guaranteed_payments` | — | 10 | — | — |
| `insurance` | 15 | 21 (instructions: insurance premiums) | 20 (instructions: insurance premiums) | 26 |
| `interest_expense` | 16a (mortgage paid to banks) or 16b (other) | 15 | 13 | 18 |
| `legal_professional` | 17 | 21 (instructions: legal and professional fees) | 20 (instructions: legal and professional fees) | 26 |
| `meals` | 24b (deductible portion; instructions: in most cases 50%) | 21 | 20 (instructions: travel, meal, and entertainment expenses) | 26 |
| `office_expense` | 18 | 21 | 20 | 26 |
| `officer_compensation` | — | — | 7 | 12 |
| `organization_startup` | 27b via Part V (startup costs; amortization on Form 4562) | 21 (instructions: certain business startup and organizational costs; amortization) | 20 (instructions: report on line 20) | 26 (instructions: report on line 26) |
| `pension_profit_sharing` | 19 | 18 (Retirement plans, etc.) | 17 | 23 |
| `rent_equipment` | 20a | 13 | 11 | 16 |
| `rent_property` | 20b | 13 | 11 | 16 |
| `repairs` | 21 | 11 | 9 | 14 |
| `salaries_wages` | 26 | 9 (other than to partners) | 8 | 13 |
| `supplies` | 22 | 21 (instructions: supplies) | 20 (instructions: supplies) | 26 |
| `taxes_licenses` | 23 | 14 | 12 | 17 |
| `travel` | 24a | 21 | 20 (instructions: travel) | 26 |
| `utilities` | 25 | 21 (instructions: utilities) | 20 (instructions: utilities) | 26 |
| `other_deductions` | 27b via Part V | 21 | 20 | 26 |
| `nondeductible` | No line (Part V: fines or penalties paid to a government not deductible) | No line | No line | No line |
Notes on the derivation:
- Form 1065 has no lines for advertising, car and truck, commissions, contract labor, insurance, legal, meals, office, supplies, travel, or utilities; they fall to line 21. Form 1120-S and Form 1120 likewise lack lines for car and truck, commissions, contract labor, insurance, legal, meals, office, supplies, travel, and utilities, which fall to line 20 and line 26 respectively. Only the items the quoted instructions name are attributed to them in the table.
- Form 1065 line 13 "Rent" and Forms 1120-S line 11 / 1120 line 16 "Rents" do not split equipment from property; Schedule C line 20 does.
- Schedule C has no line for bad debts or for organizational and start-up costs; the Part V instructions quoted above put them in Other expenses (line 48 to line 27b).
- Schedule C line 30 (business use of home) and Form 1065 line 20 / 1120-S line 19 / 1120 line 25 (energy efficient commercial buildings) have no wanderbooks category.
+76
View File
@@ -0,0 +1,76 @@
# How spreadsheet numbers are stored: Office Open XML and Excel's precision
Governs `ledger_import::xlsx` (XLSX report output). ECMA-376 and Microsoft's pages are copyrighted (© Ecma International; © Microsoft), not public domain, so per the vendoring rules (docs/spec/README.md) they are cited, with short quotes only; everything not inside a `>` blockquote is our description. Summary: a number cell is a `c` element whose type `t` defaults to `n`; its value sits as text in the `v` element, whose schema type is `ST_Xstring` (not `xsd:double`); the number's value space defaults to IEC 60559 double precision, i.e. XML Schema `double`, 53-bit mantissa. Excel itself keeps 15 significant digits.
## ECMA-376 Part 1, 5th edition (December 2016), Office Open XML File Formats — Fundamentals and Markup Language Reference
Source: <https://ecma-international.org/wp-content/uploads/ECMA-376-1_5th_edition_december_2016.zip>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`ECMA-376-1_5th_edition_december_2016.zip`): `9d0bcad9cf06054785b03762fcfadbf6bab7e54a5f9d69434e34b7fd464d4129`
SHA-256 of `OfficeOpenXML-XMLSchema-Strict.zip` → `sml.xsd` inside it: `dc4b61faf2b62d2caf875faf40bb14a8f3c0cf3dc52763a2db394a28e79634b4`
**§18.3.1.4 c (Cell): the `t` attribute**
Cited (not quoted): `t` (Cell Data Type) is an `ST_CellType` enumeration. The schema (§A.2, `CT_Cell` in `sml.xsd`) declares it with a default:
```xml
<xsd:attribute name="t" type="ST_CellType" use="optional" default="n"/>
```
**§18.18.11 ST_CellType (Cell Type)**
> n (Number) Cell containing a number.
Cited (not quoted): the other values are `b` boolean, `d` ISO 8601 date, `e` error, `inlineStr` inline rich string, `s` shared string, `str` formula string.
**§18.3.1.96 v (Cell Value)**
Cited (not quoted): `v` holds the cell's value directly unless the cell is a shared string (then it is an index into the shared string table); for a formula cell it holds the last calculated result. Its content type is `ST_Xstring` (§22.9.2.19); the schema line is:
```xml
<xsd:element name="v" type="s:ST_Xstring" minOccurs="0" maxOccurs="1"/>
```
Cited (not quoted): the section's own example writes a number cell as `<c r="B4"><v>360</v></c>` (no `t`, so type `n`).
**§18.17.2.1 Constants: precision of cell values**
> The default is therefore 53-bits of mantissa precision.
Cited (not quoted): for cell formulas and values the default precision is that of the XML Schema `double` type (http://www.w3.org/TR/xmlschema-2/#double); an application may state another precision in the Additional Characteristics part; a compliant consumer must parse numbers of arbitrary precision without error.
**§18.17.5.2 Precision: the value space**
Cited (not quoted): numbers range over values (-1)^s × m × 2^n, with s 0 or 1, 0 ≤ m < 2^53, and -1074 ≤ n ≤ 971; a note says this is patterned after the IEC 60559 double-precision 64-bit type. The characteristics markup (§22.7) may replace this default value space for a workbook. §18.17.2 adds that every arithmetic term is a number in the workbook's value space, by default IEC 60559 double precision.
## Microsoft Support: Excel specifications and limits
Source: <https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3> (redirects to <https://support.microsoft.com/en-us/excel/excel-specifications-and-limits>)
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`excel-specifications-and-limits.html`): `2bc3c02cae4a23c5914d6083313166caa8529d4462b85e51f7a121de022b509f`
**Calculation specifications and limits (table rows, transcribed)**
| Feature | Maximum limit |
|---|---|
| Number precision | 15 digits |
| Smallest allowed negative number | -2.2251E-308 |
| Smallest allowed positive number | 2.2251E-308 |
| Largest allowed positive number | 9.99999999999999E+307 |
| Largest allowed negative number | -9.99999999999999E+307 |
| Largest allowed positive number via formula | 1.7976931348623158e+308 |
| Largest allowed negative number via formula | -1.7976931348623158e+308 |
Cited (not quoted): the page applies to Excel for Microsoft 365, 2024, 2021, 2019, 2016.
## Microsoft Learn: Floating-point arithmetic may give inaccurate results in Excel
Source: <https://learn.microsoft.com/en-us/troubleshoot/microsoft-365-apps/excel/floating-point-arithmetic-inaccurate-result>
Retrieved: 2026-10-13
SHA-256 of the retrieved file (`floating-point-arithmetic-inaccurate-result.html`): `db89ffae703bb93f096add797db58d1a91a3fe0e0934d95f6f963e5a4cfe0b33`
**15 digits**
> it can only do so within 15 digits of precision.
Cited (not quoted): Excel was designed around IEEE 754 for storing and calculating floating-point numbers; it stores numbers from about 2.2250738585072E-308 to 1.79769313486232E+308 within 15 digits of precision; it does not implement denormalized numbers, infinities, or NaN (it shows errors instead).
## Consequence for money (derived, not quoted)
A cent amount written as a numeric cell is a binary double, not a decimal: 0.10 is not exactly representable. With at most 15 significant digits honored by Excel, a cent-precise amount stays exact on display only up to 13 digits before the decimal point (9,999,999,999,999.99). Money in wanderbooks stays `Decimal`; the XLSX file is a presentation of it, never a source to read money back from.
+2795 -4
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -21,6 +21,9 @@ path = "../crates/ledger-core"
[dependencies.ledger-store]
path = "../crates/ledger-store"
[dependencies.ledger-import]
path = "../crates/ledger-import"
[[bin]]
name = "fuzz_journal_line"
path = "fuzz_targets/fuzz_journal_line.rs"
@@ -64,3 +67,10 @@ path = "fuzz_targets/fuzz_store_replay.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_import_csv"
path = "fuzz_targets/fuzz_import_csv.rs"
test = false
doc = false
bench = false
+21
View File
@@ -0,0 +1,21 @@
// Fuzz target: CSV files a person hands the agent (a bank export, a chart
// of accounts) are untrusted input. Parsing must return rows or an error,
// never panic or hang, and what it accepts must be usable as money:
// every bank amount nonzero, and a chart row's code and name non-empty.
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let Ok(text) = std::str::from_utf8(data) else { return };
if let Ok(txns) = ledger_import::parse_bank_csv(text) {
for t in &txns {
assert!(!t.amount.is_zero(), "a zero amount was kept: {t:?}");
}
}
if let Ok(rows) = ledger_import::parse_chart_csv(text) {
for r in &rows {
assert!(!r.code.trim().is_empty() && !r.name.trim().is_empty(), "{r:?}");
}
}
});
+3
View File
@@ -0,0 +1,3 @@
Date,Description,Amount
2026-01-15,Coffee,-4.50
01/16/2026,Pay,"$1,200.00"
+2
View File
@@ -0,0 +1,2 @@
Date,Description,Expense,Income
2026-01-15,Coffee,4.50,
+3
View File
@@ -0,0 +1,3 @@
code,name,type,parent,tax
1000,Cash,asset,,
6000,Rent,expense,,rent_or_lease