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
287 lines
10 KiB
Rust
287 lines
10 KiB
Rust
//! Generate Intuit Interchange Format (IIF) journal entries for QuickBooks
|
|
//! Desktop, which many CPAs still run.
|
|
//!
|
|
//! ```text
|
|
//! 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.
|
|
//! ```
|
|
//!
|
|
//! 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 chrono::Datelike;
|
|
use ledger_core::{AccountId, JournalEntry, Ledger, Side};
|
|
use rust_decimal::Decimal;
|
|
use std::collections::HashMap;
|
|
use thiserror::Error;
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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("!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, entry) in entries.iter().enumerate() {
|
|
let docnum = idx + 1;
|
|
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,
|
|
});
|
|
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!(
|
|
"{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");
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// 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,
|
|
Side::Credit => -amount,
|
|
}
|
|
}
|
|
|
|
/// Two decimal places, no thousands separators.
|
|
fn format_amount(d: Decimal) -> String {
|
|
format!("{d:.2}")
|
|
}
|
|
|
|
/// 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, Money};
|
|
|
|
#[test]
|
|
fn iif_declares_accounts_then_journals() {
|
|
let mut ledger = Ledger::new(Currency::USD);
|
|
let cash = ledger
|
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
|
.unwrap();
|
|
let rev = ledger
|
|
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
|
.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 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"
|
|
);
|
|
}
|
|
}
|