From 42088e06d54b782581439a5dd44c06aa56e3e7f9 Mon Sep 17 00:00:00 2001 From: Stephen Waits <1+swaits@noreply.git.swaits.com> Date: Sun, 11 Oct 2026 03:02:24 +0000 Subject: [PATCH] Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan --- crates/ledger-import/src/iif.rs | 139 ++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 crates/ledger-import/src/iif.rs diff --git a/crates/ledger-import/src/iif.rs b/crates/ledger-import/src/iif.rs new file mode 100644 index 0000000..f1d7275 --- /dev/null +++ b/crates/ledger-import/src/iif.rs @@ -0,0 +1,139 @@ +//! Generate Intuit Interchange Format (IIF) journal entries for QuickBooks +//! Desktop, which many CPAs still run. +//! +//! Layout per entry: +//! +//! ```text +//! !TRNS TRNSTYPE DATE ACCNT NAME AMOUNT DOCNUM MEMO +//! !SPL TRNSTYPE DATE ACCNT AMOUNT DOCNUM MEMO +//! !ENDTRNS +//! TRNS GENERAL JOURNAL 01/15/2026 Cash 1000.00 1 Memo +//! SPL GENERAL JOURNAL 01/15/2026 Revenue -1000.00 1 Memo +//! ENDTRNS +//! ``` +//! +//! 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). + +use ledger_core::{JournalEntry, Side}; +use rust_decimal::Decimal; + +/// 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, +} + +/// Render posted entries as IIF text. `docnum` numbers entries sequentially +/// from 1 in the order given. +pub fn journal_to_iif(entries: &[IifEntry<'_>]) -> String { + 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("!ENDTRNS\n"); + for (idx, item) 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. + 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); + out.push_str(&format!( + "SPL\tGENERAL JOURNAL\t{date}\t{acct}\t{}\t{docnum}\t{memo}\n", + format_amount(amount), + )); + } + out.push_str("ENDTRNS\n"); + } + out +} + +/// Debit positive, credit negative. +fn signed(side: Side, amount: Decimal) -> Decimal { + match side { + Side::Debit => amount, + Side::Credit => -amount, + } +} + +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'], " ") +} + +#[cfg(test)] +mod tests { + use super::*; + use ledger_core::{AccountType, Currency, Ledger, Money}; + use rust_decimal::Decimal; + + #[test] + fn iif_round_trip_shape() { + 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, 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(); + 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")); + } +}