140 lines
4.7 KiB
Rust
140 lines
4.7 KiB
Rust
//! 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"));
|
|
}
|
|
}
|