Files
wanderbooks/crates/ledger-package/src/iif.rs
T
swaitsandClaude Opus 5.5 6c587086a0 feat(iif): carry each line's project in CLASS, the classes declared first
The P3 cutover review's open point 1: entries carry projects (classes,
B-37), but the QuickBooks Desktop file (B-34) left the CLASS field empty
on every line, and neither B-34 nor the README said so, though B-34 rule
6 says nothing changes silently.

Intuit's IIF Import Kit (vendored in docs/spec/sources/intuit-iif.md,
the kit's SHA-256 unchanged): the `!CLASS` header help says "Only the
NAME column is required" and a subclass's NAME is its parents' names
first, joined by colons; the kit's "Import First - List Declarations"
example declares `!CLASS` rows before any transaction, and its "2004
Deposit (Income)" example names the class line by line in CLASS. So the
file now declares every class in a `!CLASS` list after the accounts and
before `!TRNS`, and writes each line's class in CLASS (empty where the
line has none). Books with no class write no list, byte for byte as
before. A line whose class wasn't declared is refused
(IifError::UndeclaredClass), never written classless.

A class's NAME follows B-34's rules for an account's own name: plain
ASCII (rule 3), a colon written '-' (it would start a subclass),
trimmed, '?' if nothing is left, cut to 31 characters: Intuit's qbXML
Onscreen Reference gives `ClassAdd`'s Name "maxDT" : 31 (and 31 in the
Canadian, UK and Australian desktop editions; 100 only in Online), the
same as its `AccountAdd` Name (both JSON files vendored by SHA-256);
Conductor's QuickBooks Desktop API reference agrees and says the name is
case-insensitive. Classes whose NAMEs would match ignoring case get
" (id)", round after round, as accounts get " (code)", except that
among names that match, those already written differently from the
books (made safe, or tagged) yield first, a second " (id)" if need be:
a class's own name is kept unless another class's own name is the same
ignoring case (classes 1 "Café", 2 "Cafe", 3 "Cafe (1)" are written
"Cafe (1) (1)", "Cafe", "Cafe (1)"). Each class is tagged twice at most
and ids differ, so none is left alike (a hand-built list is refused). Each class name
written differently is an IifChange (IifField::ClassName), said one
line each in the warnings and in the package README, whose
"written differently" paragraph now names class names' limits; its IIF
line says CLASS is each line's project when there are classes. The MCP
and viewer notes count "account or class name(s)".

API: `iif_classes(ledger)` builds the list; `journal_to_iif` takes it
(`&[]` for none).

Docs: rules.md B-34 rule 8 with its sources; intuit-iif.md (the
`!CLASS` help, the list declarations and deposit examples, Intuit's SDK
department page, the qbXML Onscreen Reference's ClassAdd and AccountAdd
and Conductor's class reference, each with SHA-256);
mcp-interface.md (books.export iif, the package note); the bookspec
example iif-quickbooks-journal-file.toml (two projects: one carried and
renamed, one only declared; every line derived by hand); testing.md;
STATUS (open point 1 done; point 3 waits for the close, the owner said).

Tests: ledger-import
`iif_writes_each_lines_class_and_declares_the_classes_first`,
`iif_class_names_fit_quickbooks_and_every_change_is_listed`,
`iif_class_names_written_as_the_books_have_them_are_kept`,
`iif_refuses_an_undeclared_or_unfit_class`, property
`iif_class_names_never_clash`; ledger-package
`the_package_iif_carries_each_lines_project` and the bookspec example.
Broken to confirm: CLASS written empty fails the two exact-file tests,
the package test and the bookspec example; the " (id)" tag dropped fails
the names test and the property; tagging every name that meets (no
yielding) fails the kept-names test and the property (its shrunk case,
"A-B" beside "A\uFF1AB", kept in the regressions file).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 19:31:18 -06:00

76 lines
3.5 KiB
Rust

//! The journal as IIF, for QuickBooks Desktop: `books.export` format iif
//! and the package's `general-journal.iif`.
//!
//! ```text
//! GOAL: the CPA imports the books through a day into QuickBooks Desktop,
//! each account declared with the type QuickBooks needs, and gets
//! the balances and the profit of the package's statements.
//! SOURCE: ledger_import::iif (the IIF format and account types); rules.md
//! B-33 (as of a day), B-34; DECISION (owner, 2026-10-20, plan
//! D-36: closing entries are left out).
//! INVARIANTS:
//! 1. The cash accounts are BANK; the receivable and payable control
//! accounts AR and AP; the cost and accumulated depreciation accounts
//! of an asset bought on or before as_of FIXASSET; every other
//! account follows its type. Every account in the chart is declared.
//! 2. Every posted entry dated on or before as_of, from the first, in
//! the order posted, except period closes: QuickBooks keeps no
//! closing entries (it works out retained earnings itself), so an
//! imported close would zero its period's profit. The file's
//! balances are the trial balance as of as_of without the closes.
//! 3. Every class (project) in the books is declared, and each line
//! carries its class (rules.md B-34 rule 8).
//! ```
use chrono::NaiveDate;
use ledger_core::{AccountId, AccountType, Ledger};
/// The journal through `as_of` as IIF, via ledger-import (INVARIANTS 1-2),
/// declaring every account with its QuickBooks type: the cash accounts
/// (`cash_codes`, the books' `cash_accounts`) are BANK, the receivable and
/// payable control accounts AR and AP, the cost and accumulated
/// depreciation accounts of an asset bought by `as_of` FIXASSET, and the
/// rest follow their wanderbooks type (other current asset, other current
/// liability, equity, income, expense), and every class with each line's
/// class in CLASS. Returns the file and every text it writes differently
/// from the books.
pub fn export_iif(
ledger: &Ledger,
cash_codes: &[String],
as_of: NaiveDate,
) -> Result<ledger_import::Iif, String> {
use ledger_import::{QbAccountType as Qb, iif_accounts, iif_classes, journal_to_iif};
let fixed: std::collections::HashSet<AccountId> = ledger
.registered_assets()
.filter(|a| ledger.asset_acquired_date(a.id).is_some_and(|d| d <= as_of))
.flat_map(|a| [a.cost_account, a.accum_depr_account])
.collect();
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
let payables = ledger.role_account(&ledger_core::PAYABLES);
let accounts = iif_accounts(ledger, |a| {
if cash_codes.contains(&a.code) {
Qb::Bank
} else if Some(a.id) == receivables {
Qb::AccountsReceivable
} else if Some(a.id) == payables {
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 closes = ledger.closing_entry_ids();
let entries: Vec<_> = ledger
.posted_entries()
.filter(|e| e.date() <= as_of && !closes.contains(&e.id()))
.collect();
journal_to_iif(&accounts, &iif_classes(ledger), &entries).map_err(|e| e.to_string())
}