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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-22 19:31:18 -06:00
1 parent 528c070605
commit 6c587086a0
17 files changed
+1026 -89

No files matched your search

@@ -6,43 +6,50 @@
# the package's as_of, wrote '?' for every character past ASCII, and knew
# nothing of QuickBooks' length limits, its double quote, or its rule that
# a subaccount has its parent's type, and could declare a subaccount
# before its parent); rules.md B-34; regression tests
# before its parent; and the cutover review: it left CLASS empty though
# lines carry projects); rules.md B-34; regression tests
# `iif_docnum_is_the_entry_id`,
# `iif_writes_plain_ascii_and_lists_every_change`,
# `iif_fits_quickbooks_desktop_limits`,
# `iif_a_subaccount_of_another_type_stands_at_the_top_level`,
# `iif_declares_each_parent_before_its_subaccounts` (ledger-import
# `iif_declares_each_parent_before_its_subaccounts`,
# `iif_writes_each_lines_class_and_declares_the_classes_first`,
# `iif_class_names_fit_quickbooks_and_every_change_is_listed` (ledger-import
# tests/spec_import.rs) and `as_of_package_iif_stops_at_as_of`
# (ledger-package tests/package.rs).
#
# Setup beyond the accounts: `cash_accounts` are the bank accounts (BANK);
# an account's QuickBooks type otherwise follows its type (asset OCASSET,
# liability OCLIAB, equity EQUITY, revenue INC, expense EXP); an account's
# `parent` is its parent's code. Event N is entry N (bookspec/README.md).
# `parent` is its parent's code; project N in `setup.projects` is class N
# (the books number classes from 1). Event N is entry N (bookspec/README.md).
# Expected values: iif_lines gives the file's lines in order, each a
# string of fields joined by tabs ("\t"), every line ending CR LF in the
# file; iif_changes gives each text written differently from the books,
# in file order (the accounts as declared: by code, each after its
# parent; then the entries), as [what, books' text, file's text], where
# what is "name <code>", "number" (left out of ACCNUM when the file's
# text is empty), "top level <code>" (written apart from its parent, the
# books' text: the parent's code) or "memo <entry id>".
# parent; then the classes; then the entries), as [what, books' text,
# file's text], where what is "name <code>", "number" (left out of ACCNUM
# when the file's text is empty), "top level <code>" (written apart from
# its parent, the books' text: the parent's code), "class <id>" (a
# project's name) or "memo <entry id>".
id = "iif-quickbooks-journal-file"
appendix = "formats"
title = "A QuickBooks Desktop journal file as of a day"
rule = "An IIF journal file as of a day D MUST declare every account first (NAME, ACCNTTYPE, ACCNUM), then hold one GENERAL JOURNAL transaction per posted entry dated on or before D, period closes excepted, in the order posted: a TRNS line and SPL lines, debits positive and credits negative, dated M/D/YYYY, each numbered (DOCNUM) with the entry's id; then ENDTRNS. It MUST be ASCII text, every line ending CR LF, no field holding a tab, a line break or a double quote; an account name level MUST be at most 31 characters and no two NAMEs alike, an account number at most 7 characters and no two alike (else left out), a memo at most 4,095 characters; each parent MUST be declared before its subaccounts, and a subaccount whose QuickBooks type differs from its parent's MUST be written at the top level. Each text written differently from the books MUST be reported."
rule = "An IIF journal file as of a day D MUST declare every account first (NAME, ACCNTTYPE, ACCNUM), then hold one GENERAL JOURNAL transaction per posted entry dated on or before D, period closes excepted, in the order posted: a TRNS line and SPL lines, debits positive and credits negative, dated M/D/YYYY, each numbered (DOCNUM) with the entry's id; then ENDTRNS. It MUST be ASCII text, every line ending CR LF, no field holding a tab, a line break or a double quote; an account name level MUST be at most 31 characters and no two NAMEs alike, an account number at most 7 characters and no two alike (else left out), a memo at most 4,095 characters; each parent MUST be declared before its subaccounts, and a subaccount whose QuickBooks type differs from its parent's MUST be written at the top level. Every project MUST be declared as a class (NAME, one level of at most 31 characters, no two alike) after the accounts and before the transactions, and each line's project MUST be its CLASS. Each text written differently from the books MUST be reported."
status = "CHOICE"
sources = [
"Intuit, IIF Overview: import kit, sample files, and headers: IIF files are ASCII text, tab-separated; an account not already in the company file is created as a new bank account (docs/spec/sources/intuit-iif.md)",
"Intuit, IIF Import Kit, IIF Header Help !TRNS, !SPL, !ACCNT: TRNS and SPL lines then ENDTRNS; \"Debit amounts are always positive, credit amounts are always negative\"; DOCNUM is the transaction's number; a subaccount's NAME its parents' names first, joined by colons (docs/spec/sources/intuit-iif.md)",
"Intuit, IIF Import Kit, 2007 General Journal Entry example file: GENERAL JOURNAL, M/D/YYYY, every line ending CR LF (docs/spec/sources/intuit-iif.md)",
"Intuit, Character limitations for fields in QuickBooks (US, updated 8/3/2026): Account Name 31, Account Numbers 7, Memo 4,095 (docs/spec/sources/intuit-iif.md)",
"Intuit, IIF Import Kit, IIF Header Help !CLASS: \"Only the NAME column is required\"; a subclass's NAME its parents' names first, colons between; the kit's list declarations example declares !CLASS before any transaction (docs/spec/sources/intuit-iif.md)",
"Conductor, QuickBooks Desktop API reference, Create a class: a class's name, \"Maximum length: 31 characters\", case-insensitive (docs/spec/sources/intuit-iif.md; not Intuit: Intuit states no class limit)",
"Intuit, Add, edit, or delete accounts in QuickBooks Desktop (updated 8/4/2026): \"The parent and subaccount must be the same account type.\" (docs/spec/sources/intuit-iif.md)",
"Reckon, IIF import changes a double quote to a single one by design; J. Clark (2003): QuickBooks uses double quotes as text delimiters (docs/spec/sources/intuit-iif.md; not Intuit)",
"DECISION D-36 (owner, 2026-10-20): plain ASCII, what isn't ASCII transliterated and listed; CR LF; DOCNUM the entry's id; closes left out (wanderbooks plan/served.md; rules.md B-34)",
]
tags = ["iif", "quickbooks", "as-of", "format", "transliteration"]
tags = ["iif", "quickbooks", "as-of", "format", "transliteration", "projects"]
[setup]
currency = "USD"
@@ -96,6 +103,14 @@ code = "6100"
name = "Professional services and consulting fees"
type = "expense"
# Class 1: a colon and an em dash, written "-" and "--".
[[setup.projects]]
name = "Café: lunch — rebrand"
# Class 2: declared though no line carries it, as every account is.
[[setup.projects]]
name = "Glaze tests"
# Entry 1.
[[events]]
date = "2026-01-02"
@@ -105,12 +120,12 @@ lines = [
{ account = "3000", side = "credit", amount = "5000.00" },
]
# Entry 2. An em dash and double quotes.
# Entry 2. An em dash and double quotes; the meal is project 1's.
[[events]]
date = "2026-01-15"
description = "Lunch — \"team\""
lines = [
{ account = "6000", side = "debit", amount = "42.50" },
{ account = "6000", side = "debit", amount = "42.50", project = "Café: lunch — rebrand" },
{ account = "1000", side = "credit", amount = "42.50" },
]
@@ -154,13 +169,16 @@ values = [
"ACCNT\tCafe meals\tEXP\t6000",
"ACCNT\tCafe meals:Snacks\tEXP\t5900",
"ACCNT\tProfessional services and consu\tEXP\t6100",
"!CLASS\tNAME",
"CLASS\tCafe- lunch -- rebrand",
"CLASS\tGlaze tests",
"!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO",
"!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO",
"!ENDTRNS",
"TRNS\t\tGENERAL JOURNAL\t1/2/2026\tChecking\t\t5000.00\t1\tOwner contribution",
"SPL\t\tGENERAL JOURNAL\t1/2/2026\tOwner capital\t\t-5000.00\t1\tOwner contribution",
"ENDTRNS",
"TRNS\t\tGENERAL JOURNAL\t1/15/2026\tCafe meals\t\t42.50\t2\tLunch -- 'team'",
"TRNS\t\tGENERAL JOURNAL\t1/15/2026\tCafe meals\tCafe- lunch -- rebrand\t42.50\t2\tLunch -- 'team'",
"SPL\t\tGENERAL JOURNAL\t1/15/2026\tChecking\t\t-42.50\t2\tLunch -- 'team'",
"ENDTRNS",
"TRNS\t\tGENERAL JOURNAL\t1/20/2026\tProfessional services and consu\t\t300.00\t3\tConsulting",
@@ -179,5 +197,6 @@ values = [
["number", "12345678", ""],
["name 6000", "Café meals", "Cafe meals"],
["name 6100", "Professional services and consulting fees", "Professional services and consu"],
["class 1", "Café: lunch — rebrand", "Cafe- lunch -- rebrand"],
["memo 2", "Lunch — \"team\"", "Lunch -- 'team'"],
]
+1 -1
View File
@@ -188,7 +188,7 @@ impl Job {
}
if p.iif_changes > 0 {
notes.push(format!(
"{} account name(s), number(s), place(s) or memo(s) are written differently in its QuickBooks file, to fit QuickBooks Desktop: the README says how.",
"{} account or class name(s), number(s), place(s) or memo(s) are written differently in its QuickBooks file, to fit QuickBooks Desktop: the README says how.",
p.iif_changes
));
}
+211 -15
View File
@@ -10,7 +10,11 @@
//! rules.md B-34; Intuit's "Character limitations for fields in
//! QuickBooks" (account name 31, account number 7, memo 4,095);
//! DECISION (owner, 2026-10-20, plan D-36: plain ASCII,
//! transliterated and listed; CR LF line endings).
//! transliterated and listed; CR LF line endings); the kit's
//! `!CLASS` header help and its list declarations example (a
//! class's NAME; lists before transactions); a class name of 31
//! characters at most (Intuit's qbXML Onscreen Reference,
//! `ClassAdd` Name).
//! INVARIANTS:
//! 1. Accounts MUST be declared first (`!ACCNT`: NAME, ACCNTTYPE, ACCNUM),
//! each after its parent, BECAUSE QuickBooks creates an unknown
@@ -59,6 +63,20 @@
//! and takes one such account a transaction (Intuit staff and help;
//! sources/intuit-iif.md). [`Iif::receivable_payable`] lists the
//! entries with such a line, for the human to be told.
//! 9. Each line's class (a project) MUST be in its CLASS field, and the
//! classes declared first (`!CLASS`: NAME), after the accounts and
//! before the transactions, BECAUSE the kit puts every list before
//! what uses it (a name it doesn't know lands on the wrong list or
//! takes a default). A class's NAME is written as an account's own
//! level is (INVARIANTS 2, 4: plain ASCII, a colon a '-' since a
//! colon starts a subclass, trimmed, at most [`NAME_MAX`]); classes
//! whose NAMEs would match ignoring case get " (their id)" until none
//! does, as accounts get their codes, except that the names already
//! written differently yield first (another id each time need be): a name
//! written as the books have it is kept unless another class's own
//! name is the same ignoring case. NEVER write a line whose class
//! wasn't declared: refuse instead. Books with no class write no
//! `!CLASS` list, as before.
//! ```
//!
//! Layout (each `→` is a tab; every line ends CR LF):
@@ -66,20 +84,23 @@
//! ```text
//! !ACCNT→NAME→ACCNTTYPE→ACCNUM
//! ACCNT→Cash→BANK→1000
//! !CLASS→NAME
//! CLASS→Fern & Field rebrand
//! !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
//! SPL→→GENERAL JOURNAL→1/15/2026→Revenue→Fern & Field rebrand→-1000.00→1→Sale
//! ENDTRNS
//! ```
//!
//! (DOCNUM 1: the sale is entry 1 in its books.)
//! (DOCNUM 1: the sale is entry 1 in its books; its revenue line carries
//! the project.)
use chrono::Datelike;
use deunicode::AsciiChars;
use ledger_core::parties::{has_full_tin, redact_full_tins};
use ledger_core::{AccountId, EntryId, JournalEntry, Ledger, Side};
use ledger_core::{AccountId, ClassId, EntryId, JournalEntry, Ledger, Side};
use rust_decimal::Decimal;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt;
@@ -188,14 +209,27 @@ pub struct IifAccount {
pub apart_from: Option<(String, QbAccountType)>,
}
/// One class (a project) as QuickBooks will know it, and as the books keep
/// it (INVARIANT 9).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IifClass {
/// The wanderbooks class.
pub id: ClassId,
/// Its NAME in the file, already made safe for IIF ([`iif_classes`]
/// builds these).
pub written: String,
/// Its name as the books keep it (for [`Iif::changes`]).
pub name: String,
}
/// What [`journal_to_iif`] writes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Iif {
/// The file: ASCII text, every line ending CR LF.
pub text: String,
/// Each account name, number or place and each memo the file writes
/// differently from the books (INVARIANT 7), in file order: the
/// accounts as given, then the entries as given.
/// differently from the books (INVARIANTS 7, 9), in file order: the
/// accounts as given, then the classes, then the entries as given.
pub changes: Vec<IifChange>,
/// The entries with a line on an accounts receivable or accounts
/// payable account, in file order: the file names no customer or
@@ -242,6 +276,12 @@ pub enum IifField {
/// Its parent's.
parent: QbAccountType,
},
/// A class's name (a project's): its NAME in the `!CLASS` list and
/// the CLASS field (INVARIANT 9).
ClassName {
/// The class's id in the books.
class: ClassId,
},
/// An entry's memo: its description in the books.
Memo {
/// The entry.
@@ -292,6 +332,9 @@ impl fmt::Display for IifChange {
own.keyword(),
parent.keyword()
),
IifField::ClassName { class } => {
format!("class {class}'s name {books:?} is written {written:?}")
}
IifField::Memo { entry } => {
format!("entry {entry}'s memo {books:?} is written {written:?}")
}
@@ -423,6 +466,38 @@ pub enum IifError {
/// The accounts' codes in the books.
codes: Vec<String>,
},
/// An entry's line has a class that wasn't declared (INVARIANT 9).
#[error("entry {entry} uses class {class}, which isn't in the class list")]
UndeclaredClass {
/// The entry.
entry: u64,
/// The class.
class: ClassId,
},
/// Two or more classes would have one name in QuickBooks
/// (INVARIANT 9; [`iif_classes`] never builds them).
#[error(
"classes {} would have the same name in QuickBooks, {name:?}: rename one",
.classes.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
)]
ClassNameCollision {
/// The name, as the file would write it.
name: String,
/// The classes' ids in the books.
classes: Vec<ClassId>,
},
/// A class's NAME is empty, holds a colon, or is longer than
/// QuickBooks Desktop holds (INVARIANT 9; [`iif_classes`] never builds
/// one).
#[error(
"class {class}'s name in QuickBooks, {name:?}, isn't one level of 1 to {NAME_MAX} characters"
)]
ClassNameInvalid {
/// The class's id in the books.
class: ClassId,
/// The NAME, as the file would write it.
name: String,
},
/// An account's NAME has a level longer than QuickBooks Desktop holds
/// (INVARIANT 2; [`iif_accounts`] never builds one).
#[error(
@@ -548,6 +623,68 @@ pub fn iif_accounts(
parents_first(out, &parents)
}
/// Every class (project) in the books as QuickBooks will know it, in id
/// order (INVARIANT 9): its name made safe as an account's own level is
/// (plain ASCII, a colon a '-', trimmed, "?" if nothing is left, cut to
/// [`NAME_MAX`]), and the classes whose NAMEs would match ignoring ASCII
/// case given " (id)" after their names (cut to make room), round after
/// round until none matches. Where they meet, the names already written
/// differently from the books (made safe, or tagged) yield first, so a
/// name written exactly as the books have it keeps it unless another
/// class's own name is the same ignoring case: "Café" beside "Cafe" is
/// "Cafe (1)", "Cafe" stays, and if a class is named "Cafe (1)" too, the
/// changed one is tagged again, "Cafe (1) (1)"; two "Rebrand" classes
/// are "Rebrand (2)" and "Rebrand (5)", and a third named "Rebrand (2)"
/// keeps its name while the tagged one becomes "Rebrand (2) (2)"; a
/// changed name meeting own names again and again is tagged again and
/// again ("Café" beside "Cafe", "Cafe (1)" and "Cafe (1) (1)" is "Cafe
/// (1) (1) (1)"). A tag that changes nothing (a name already cut to
/// [`NAME_MAX`] and ending in its own " (id)") is not made: that name
/// stands and the others meeting it are tagged. Each tag lengthens a
/// name or leaves it at [`NAME_MAX`] ending in its own id, after which it
/// is never tagged again; two names so ended differ by their ids. So
/// every round that finds two alike tags one, and the rounds end with no
/// two NAMEs alike.
pub fn iif_classes(ledger: &Ledger) -> Vec<IifClass> {
let classes = ledger.list_classes();
let own: Vec<String> = classes.iter().map(|c| segment(&c.name)).collect();
let mut written = own.clone();
loop {
let mut by_name: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (i, w) in written.iter().enumerate() {
by_name.entry(w.to_ascii_lowercase()).or_default().push(i);
}
let mut more = false;
for alike in by_name.into_values().filter(|ix| ix.len() > 1) {
let can: Vec<usize> = alike
.into_iter()
.filter(|&i| with_tag(&written[i], &classes[i].id.to_string()) != written[i])
.collect();
let yielding: Vec<usize> = can
.iter()
.copied()
.filter(|&i| written[i] != classes[i].name)
.collect();
for i in if yielding.is_empty() { can } else { yielding } {
written[i] = with_tag(&written[i], &classes[i].id.to_string());
more = true;
}
}
if !more {
break;
}
}
classes
.iter()
.zip(written)
.map(|(c, written)| IifClass {
id: c.id,
written,
name: c.name.clone(),
})
.collect()
}
/// `accounts`, in the order given but each after its parent (INVARIANT 1:
/// a subaccount's NAME names its parent, which QuickBooks must know
/// first): the first account whose parent is already placed (or that has
@@ -584,14 +721,19 @@ fn parents_first(
order.into_iter().filter_map(|i| slots[i].take()).collect()
}
/// Render posted entries as IIF: the accounts declared first, then one
/// transaction per entry in the order given, each numbered (DOCNUM) with
/// its entry id; every text the file writes differently from the books,
/// Render posted entries as IIF: the accounts declared first, then the
/// classes (none: no class list), then one transaction per entry in the
/// order given, each numbered (DOCNUM) with its entry id and each line's
/// class in CLASS; every text the file writes differently from the books,
/// and the entries with a line on accounts receivable or payable
/// (INVARIANTS 1-8). Refused when an entry uses an account `accounts`
/// lacks, two accounts would have one name, or a name has a level too long
/// for QuickBooks.
pub fn journal_to_iif(accounts: &[IifAccount], entries: &[&JournalEntry]) -> Result<Iif, IifError> {
/// (INVARIANTS 1-9). Refused when an entry uses an account `accounts`
/// lacks or a class `classes` lacks, two accounts or two classes would
/// have one name, or a name has a level too long for QuickBooks.
pub fn journal_to_iif(
accounts: &[IifAccount],
classes: &[IifClass],
entries: &[&JournalEntry],
) -> Result<Iif, IifError> {
// Made safe again: it changes nothing iif_accounts built, and an
// account built some other way can't break the file.
let paths: Vec<String> = accounts.iter().map(|a| field(&a.path)).collect();
@@ -636,6 +778,40 @@ pub fn journal_to_iif(accounts: &[IifAccount], entries: &[&JournalEntry]) -> Res
{
*uses.entry(n.to_ascii_lowercase()).or_default() += 1;
}
// INVARIANT 9's names: made safe again (it changes nothing
// iif_classes built), one level each, no two alike.
let class_names: Vec<String> = classes.iter().map(|c| field(&c.written)).collect();
if let Some((c, name)) = classes.iter().zip(&class_names).find(|(_, n)| {
n.is_empty() || n.len() > NAME_MAX || n.contains(':') || n.trim() != n.as_str()
}) {
return Err(IifError::ClassNameInvalid {
class: c.id,
name: name.clone(),
});
}
let mut by_class_name: BTreeMap<String, Vec<ClassId>> = BTreeMap::new();
for (c, name) in classes.iter().zip(&class_names) {
by_class_name
.entry(name.to_ascii_lowercase())
.or_default()
.push(c.id);
}
if let Some((lower, ids)) = by_class_name.iter().find(|(_, ids)| ids.len() > 1) {
let name = class_names
.iter()
.find(|n| n.to_ascii_lowercase() == *lower)
.cloned()
.unwrap_or_default();
return Err(IifError::ClassNameCollision {
name,
classes: ids.clone(),
});
}
let class_of: HashMap<ClassId, &str> = classes
.iter()
.zip(&class_names)
.map(|(c, n)| (c.id, n.as_str()))
.collect();
let by_id: HashMap<AccountId, (&str, QbAccountType)> = accounts
.iter()
.zip(&paths)
@@ -695,6 +871,19 @@ pub fn journal_to_iif(accounts: &[IifAccount], entries: &[&JournalEntry]) -> Res
});
}
}
if !classes.is_empty() {
out.push_str(&format!("!CLASS\tNAME{EOL}"));
for (c, name) in classes.iter().zip(&class_names) {
out.push_str(&format!("CLASS\t{name}{EOL}"));
if *name != c.name {
changes.push(IifChange {
field: IifField::ClassName { class: c.id },
books: c.name.clone(),
written: name.clone(),
});
}
}
}
out.push_str(&format!(
"!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO{EOL}"
));
@@ -736,9 +925,16 @@ pub fn journal_to_iif(accounts: &[IifAccount], entries: &[&JournalEntry]) -> Res
qb_type,
QbAccountType::AccountsReceivable | QbAccountType::AccountsPayable
);
let class = match line.class_id {
None => "",
Some(c) => class_of.get(&c).ok_or(IifError::UndeclaredClass {
entry: entry.id(),
class: c,
})?,
};
let keyword = if n == 0 { "TRNS" } else { "SPL" };
out.push_str(&format!(
"{keyword}\t\tGENERAL JOURNAL\t{date}\t{account}\t\t{}\t{docnum}\t{memo}{EOL}",
"{keyword}\t\tGENERAL JOURNAL\t{date}\t{account}\t{class}\t{}\t{docnum}\t{memo}{EOL}",
format_amount(signed(line.side, line.amount.amount)),
));
}
@@ -911,7 +1107,7 @@ mod tests {
AccountType::Asset => QbAccountType::Bank,
_ => QbAccountType::Income,
});
let iif = journal_to_iif(&accounts, &[ledger.get_entry(id).unwrap()]).unwrap();
let iif = journal_to_iif(&accounts, &[], &[ledger.get_entry(id).unwrap()]).unwrap();
assert_eq!(
iif.text,
"!ACCNT\tNAME\tACCNTTYPE\tACCNUM\r\n\
+2 -2
View File
@@ -27,8 +27,8 @@ pub use asset_register::{asset_register_pdf, asset_register_xlsx};
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::{
Iif, IifAccount, IifChange, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX,
QbAccountType, iif_accounts, journal_to_iif,
Iif, IifAccount, IifChange, IifClass, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX,
NUMBER_MAX, QbAccountType, iif_accounts, iif_classes, journal_to_iif,
};
pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf};
pub use pdf::{PdfError, statement_pdf};
@@ -5,3 +5,5 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 32fa871fdd86d0ed0796633bcaab87af1143b4541818db7017872dd006fee657 # shrinks to (specs, oe, end_off) = ([EntrySpec { day: 0, accts: [7, 14], k: 1, debits: [100, 100, 100], seeds: [0, 6482613244230351999, 7089996084298427956], memo: "6—#" }], false, 156)
cc 6c74a7c399820c00cd4d81ca1a0b2497c99a7393b07fd1d3896d522232313ef2 # shrinks to names = ["A-B", "A:B"]
cc d1ab0ff4c1d81d642a16125bc80ea5bfa02e7a015ead08aa0f9f37ed9dd82838 # shrinks to names = ["Café", "Cafe (1)", "Cafe (1) (1)", "Café"]
+445 -26
View File
@@ -21,10 +21,10 @@ use ledger_core::{
use ledger_import::chart_csv::ChartCsvError;
use ledger_import::report::{self, Member, ReportMeta, Row, RowKind, Statement, YearEarnings};
use ledger_import::{
BankCsvError, BankTransaction, DateOrder, EntityType, Iif, IifAccount, IifChange, IifError,
IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX, QbAccountType, XlsxReport, chart_template,
format_amount, iif_accounts, journal_to_iif, parse_bank_csv, parse_bank_csv_with,
parse_chart_csv, report_to_xlsx, statement_pdf,
BankCsvError, BankTransaction, DateOrder, EntityType, Iif, IifAccount, IifChange, IifClass,
IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX, QbAccountType, XlsxReport,
chart_template, format_amount, iif_accounts, iif_classes, journal_to_iif, parse_bank_csv,
parse_bank_csv_with, parse_chart_csv, report_to_xlsx, statement_pdf,
};
use proptest::prelude::*;
use rust_decimal::{Decimal, RoundingStrategy};
@@ -1121,7 +1121,7 @@ fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) {
);
}
let keywords = [
"!ACCNT", "ACCNT", "!TRNS", "!SPL", "!ENDTRNS", "TRNS", "SPL", "ENDTRNS",
"!ACCNT", "ACCNT", "!CLASS", "CLASS", "!TRNS", "!SPL", "!ENDTRNS", "TRNS", "SPL", "ENDTRNS",
];
let mut headers: HashMap<&str, Vec<&str>> = HashMap::new();
let mut declared: HashSet<String> = HashSet::new();
@@ -1166,6 +1166,12 @@ fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) {
}
declared.insert(name.to_string());
}
// INVARIANT 9: the classes, after the accounts, before the
// transactions (check_iif_classes checks the rest).
"CLASS" => {
assert!(!seen_trns_header, "CLASS after !TRNS");
assert!(headers.contains_key("ACCNT"), "CLASS before the accounts");
}
"TRNS" => {
assert!(current.is_none(), "TRNS inside a transaction");
current = Some(vec![f]);
@@ -1583,7 +1589,7 @@ proptest! {
let accounts = iif_accounts(&b.ledger, qb);
prop_assert_eq!(accounts.len(), b.ledger.chart().len());
let entries: Vec<&JournalEntry> = b.ledger.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
check_iif(&iif.text, &accounts, &entries);
check_changes(&iif, &accounts, &entries);
// INVARIANT 4: a memo of printable ASCII is written as itself.
@@ -1636,7 +1642,7 @@ proptest! {
ids.push(l.replay_add_account(&code_of(i), name, AccountType::Expense, parent, false).unwrap());
}
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
match journal_to_iif(&accounts, &[]) {
match journal_to_iif(&accounts, &[], &[]) {
Ok(iif) => {
check_iif(&iif.text, &accounts, &[]);
check_changes(&iif, &accounts, &[]);
@@ -1706,7 +1712,7 @@ fn iif_paths_and_transliteration() {
assert_eq!(path("4100"), "Interest 'income'");
assert_eq!(path("3000"), "Capital -- Ada");
assert_eq!(path("6010"), "Cafe meals");
let iif = journal_to_iif(&accounts, &[]).unwrap();
let iif = journal_to_iif(&accounts, &[], &[]).unwrap();
let changed: Vec<String> = iif.changes.iter().map(ToString::to_string).collect();
assert_eq!(
changed,
@@ -1747,7 +1753,7 @@ fn iif_module_layout_example() {
}
});
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
let lines: Vec<&str> = iif.text.lines().map(|l| l.trim_end_matches('\r')).collect();
assert_eq!(lines[0], "!ACCNT\tNAME\tACCNTTYPE\tACCNUM");
assert!(lines.contains(&"ACCNT\tCash\tBANK\t1000"));
@@ -1775,7 +1781,7 @@ fn iif_memo_with_tabs_and_breaks() {
l.post_entry(e).unwrap();
let accounts = iif_accounts(&l, |_| QbAccountType::Bank);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
check_iif(&iif.text, &accounts, &entries);
check_changes(&iif, &accounts, &entries);
}
@@ -1799,7 +1805,7 @@ fn iif_refuses_an_undeclared_account() {
let entries: Vec<&JournalEntry> = b.ledger.posted_entries().collect();
let eid: EntryId = entries[0].id();
assert_eq!(
journal_to_iif(&accounts, &entries),
journal_to_iif(&accounts, &[], &entries),
Err(IifError::UndeclaredAccount {
entry: eid,
account: sales
@@ -1855,7 +1861,7 @@ fn iif_docnum_is_the_entry_id() {
entries.iter().map(|e| e.id()).collect::<Vec<_>>(),
[3, 2, 4]
);
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
check_iif(&iif.text, &accounts, &entries);
// Each record's keyword and DOCNUM, in file order.
let docnums: Vec<(&str, &str)> = iif
@@ -1919,7 +1925,7 @@ fn iif_writes_plain_ascii_and_lists_every_change() {
}
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
check_iif(&iif.text, &accounts, &entries);
check_changes(&iif, &accounts, &entries);
let accnt: Vec<&str> = iif
@@ -1980,7 +1986,7 @@ fn iif_every_line_ends_cr_lf() {
l.post_entry(e).unwrap();
let accounts = iif_accounts(&l, |_| QbAccountType::Bank);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let text = journal_to_iif(&accounts, &entries).unwrap().text;
let text = journal_to_iif(&accounts, &[], &entries).unwrap().text;
let crlf = text.matches("\r\n").count();
assert_eq!(crlf, text.matches('\n').count(), "every LF follows a CR");
assert_eq!(
@@ -2048,7 +2054,7 @@ fn iif_distinct_accounts_never_share_a_name() {
("6800", "? (6800)"),
]
);
let iif = journal_to_iif(&accounts, &[]).unwrap();
let iif = journal_to_iif(&accounts, &[], &[]).unwrap();
check_iif(&iif.text, &accounts, &[]);
check_changes(&iif, &accounts, &[]);
assert_eq!(iif.changes.len(), 9, "{:?}", iif.changes);
@@ -2070,7 +2076,7 @@ fn iif_distinct_accounts_never_share_a_name() {
};
assert_eq!(path("6100"), "Software (6100)");
assert_eq!(path("6900"), "Software (6100) (6900)");
let iif = journal_to_iif(&accounts, &[]).unwrap();
let iif = journal_to_iif(&accounts, &[], &[]).unwrap();
check_iif(&iif.text, &accounts, &[]);
check_changes(&iif, &accounts, &[]);
// Two "Software" whose codes are written alike (a fullwidth digit):
@@ -2081,7 +2087,7 @@ fn iif_distinct_accounts_never_share_a_name() {
.unwrap();
}
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
let err = journal_to_iif(&accounts, &[]).unwrap_err();
let err = journal_to_iif(&accounts, &[], &[]).unwrap_err();
assert_eq!(
err,
IifError::NameCollision {
@@ -2131,7 +2137,7 @@ fn iif_a_subaccount_of_another_type_stands_at_the_top_level() {
_ => QbAccountType::OtherCurrentAsset,
});
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
check_iif(&iif.text, &accounts, &entries);
check_changes(&iif, &accounts, &entries);
let lines: Vec<&str> = iif
@@ -2186,7 +2192,7 @@ fn iif_declares_each_parent_before_its_subaccounts() {
add("6100", "Software", None);
add("5950", "Toner", Some("5900"));
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
let iif = journal_to_iif(&accounts, &[]).unwrap();
let iif = journal_to_iif(&accounts, &[], &[]).unwrap();
check_iif(&iif.text, &accounts, &[]);
let names: Vec<&str> = iif
.text
@@ -2219,7 +2225,7 @@ fn iif_change_warnings_are_one_line_each() {
l.post_entry(e).unwrap();
let accounts = iif_accounts(&l, |_| QbAccountType::Bank);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
assert_eq!(
iif.changes
.iter()
@@ -2266,7 +2272,7 @@ fn iif_fits_quickbooks_desktop_limits() {
l.post_entry(e).unwrap();
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
check_iif(&iif.text, &accounts, &entries);
check_changes(&iif, &accounts, &entries);
let accnt: Vec<&str> = iif
@@ -2321,7 +2327,7 @@ fn iif_fits_quickbooks_desktop_limits() {
let mut bad = accounts.clone();
bad[1].path = "x".repeat(NAME_MAX + 1);
assert_eq!(
journal_to_iif(&bad, &[]).unwrap_err(),
journal_to_iif(&bad, &[], &[]).unwrap_err(),
IifError::NameTooLong {
code: bad[1].code.clone(),
name: "x".repeat(NAME_MAX + 1),
@@ -2367,7 +2373,7 @@ fn iif_warnings_group_the_memos_by_what_is_written_differently() {
let zoe = post(&mut l, "Lunch, \"Zo\u{EB}\"\nteam \u{5317}\u{4EAC}");
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
check_changes(&iif, &accounts, &entries);
assert_eq!(iif.changes.len(), 1 + 100 + 1 + 1, "one per name and memo");
assert!(
@@ -2446,7 +2452,7 @@ fn iif_warnings_never_hold_a_tin() {
}
let accounts = iif_accounts(&l, |_| QbAccountType::Bank);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
check_changes(&iif, &accounts, &entries);
assert_eq!(
iif.warnings(),
@@ -2490,7 +2496,7 @@ fn iif_keeps_a_tin_readable_to_the_package_scan() {
}
let accounts = iif_accounts(&l, |_| QbAccountType::Bank);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
let memos = iif_memos(&iif.text);
assert_eq!(memos, ["Paid SSN 123-45-6789", "Paid SSN 123 45 6789"]);
for m in &memos {
@@ -4433,7 +4439,7 @@ fn iif_field_transliteration_exact() {
l.post_entry(e).unwrap();
let accounts = iif_accounts(&l, |_| QbAccountType::Bank);
let entries: Vec<&JournalEntry> = l.posted_entries().collect();
let iif = journal_to_iif(&accounts, &entries).unwrap();
let iif = journal_to_iif(&accounts, &[], &entries).unwrap();
assert_eq!(iif_memos(&iif.text), ["a b c d ---- 'e' '' x y"]);
}
@@ -4840,3 +4846,416 @@ fn asset_register_xlsx_empty() {
assert_eq!(text_cell(&c, (12, 0)), "Difference (unregistered cost)");
assert_eq!(number_cell(&c, (12, 5)), dec("999.00"));
}
// ---------------------------------------------------------------------------
// iif INVARIANT 9: classes (projects)
// ---------------------------------------------------------------------------
/// iif INVARIANT 9 against the file: when there are classes, a `!CLASS`
/// list with a NAME column after the accounts and before `!TRNS`, one
/// CLASS row per class in the order given, each NAME the class's written
/// name, one level (no colon) of 1 to NAME_MAX printable ASCII
/// characters, no two alike ignoring case; when there are none, no list.
/// Every TRNS and SPL line's CLASS is its line's class as written, or
/// empty for a line with none (the lines matched by account and amount).
fn check_iif_classes(text: &str, classes: &[IifClass], entries: &[&JournalEntry]) {
let lines: Vec<&str> = text.split("\r\n").collect();
let at = |kw: &str| lines.iter().position(|l| l.split('\t').next() == Some(kw));
let rows: Vec<&str> = lines
.iter()
.filter_map(|l| l.strip_prefix("CLASS\t"))
.collect();
if classes.is_empty() {
assert_eq!(at("!CLASS"), None, "no classes, no list");
assert!(rows.is_empty());
} else {
let header = at("!CLASS").expect("a !CLASS list");
assert_eq!(lines[header], "!CLASS\tNAME");
assert!(at("ACCNT").is_none_or(|a| a < header), "the accounts first");
assert!(header < at("!TRNS").unwrap(), "the classes before !TRNS");
let names: Vec<&str> = classes.iter().map(|c| c.written.as_str()).collect();
assert_eq!(rows, names, "one row per class, in order");
for n in &rows {
assert!(plain(n) && !n.contains(':'), "{n:?}");
assert!(
!n.is_empty() && n.len() <= NAME_MAX && *n == n.trim(),
"{n:?}"
);
}
let lower: HashSet<String> = rows.iter().map(|n| n.to_ascii_lowercase()).collect();
assert_eq!(lower.len(), rows.len(), "two class NAMEs alike: {rows:?}");
}
let written: HashMap<ledger_core::ClassId, &str> =
classes.iter().map(|c| (c.id, c.written.as_str())).collect();
let header: Vec<&str> = lines
.iter()
.find(|l| l.starts_with("!TRNS\t"))
.unwrap()
.split('\t')
.collect();
let (class_at, amount_at, doc_at) = (
header.iter().position(|c| *c == "CLASS").unwrap(),
header.iter().position(|c| *c == "AMOUNT").unwrap(),
header.iter().position(|c| *c == "DOCNUM").unwrap(),
);
for e in entries {
let mut got: Vec<(String, String)> = lines
.iter()
.map(|l| l.split('\t').collect::<Vec<_>>())
.filter(|f| (f[0] == "TRNS" || f[0] == "SPL") && f[doc_at] == e.id().to_string())
.map(|f| (f[amount_at].to_string(), f[class_at].to_string()))
.collect();
let mut want: Vec<(String, String)> = e
.lines()
.iter()
.map(|l| {
let a = l.amount.amount;
let signed = if l.side == Side::Debit { a } else { -a };
(
format!("{signed:.2}"),
l.class_id.map_or("", |c| written[&c]).to_string(),
)
})
.collect();
got.sort();
want.sort();
assert_eq!(got, want, "entry {}'s classes", e.id());
}
}
/// Books with two classes and a sale whose revenue line is one of them.
fn class_books() -> (Ledger, EntryId) {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let rev = l
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let rebrand = l.add_class("Fern & Field rebrand").unwrap();
l.add_class("Archive").unwrap();
let id = l.create_entry(d(2026, 1, 15), "Sale");
let m = Money::new(dec("1000.00"), Currency::USD);
l.add_line(id, cash, Side::Debit, m).unwrap();
l.add_line(id, rev, Side::Credit, m).unwrap();
l.set_line_class(id, 1, Some(rebrand)).unwrap();
l.post_entry(id).unwrap();
(l, id)
}
/// iif INVARIANT 9, the layout of the module's doc: every class declared
/// in a `!CLASS` list after the accounts and before the transactions
/// (the kit's list declarations example; "Only the NAME column is
/// required", its `!CLASS` header help), and each line's class in its
/// CLASS field, empty where the line has none. A name written as itself
/// is no change.
#[test]
fn iif_writes_each_lines_class_and_declares_the_classes_first() {
let (l, id) = class_books();
let accounts = iif_accounts(&l, |a| match a.account_type {
AccountType::Asset => QbAccountType::Bank,
_ => QbAccountType::Income,
});
let classes = iif_classes(&l);
let entries = [l.get_entry(id).unwrap()];
let iif = journal_to_iif(&accounts, &classes, &entries).unwrap();
assert_eq!(
iif.text,
"!ACCNT\tNAME\tACCNTTYPE\tACCNUM\r\n\
ACCNT\tCash\tBANK\t1000\r\n\
ACCNT\tRevenue\tINC\t4000\r\n\
!CLASS\tNAME\r\n\
CLASS\tFern & Field rebrand\r\n\
CLASS\tArchive\r\n\
!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\r\n\
!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\r\n\
!ENDTRNS\r\n\
TRNS\t\tGENERAL JOURNAL\t1/15/2026\tCash\t\t1000.00\t1\tSale\r\n\
SPL\t\tGENERAL JOURNAL\t1/15/2026\tRevenue\tFern & Field rebrand\t-1000.00\t1\tSale\r\n\
ENDTRNS\r\n"
);
assert!(iif.changes.is_empty() && iif.warnings().is_empty());
check_iif(&iif.text, &accounts, &entries);
check_iif_classes(&iif.text, &classes, &entries);
// No classes, no list: the file books without classes always had.
let plain_books = journal_to_iif(&accounts, &[], &[]).unwrap();
assert!(
!plain_books
.text
.split("\r\n")
.any(|l| l.starts_with("CLASS") || l.starts_with("!CLASS")),
"{}",
plain_books.text
);
}
/// iif INVARIANT 9: a class's NAME is written as an account's own level
/// is (B-34 rules 3, 4): plain ASCII, a colon a '-' (a colon starts a
/// subclass, the `!CLASS` header help), trimmed, at most NAME_MAX; names
/// alike ignoring case once written get " (id)", round after round, as
/// accounts get their codes; and each name written differently is a
/// change, said in the warnings one line each.
#[test]
fn iif_class_names_fit_quickbooks_and_every_change_is_listed() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let exp = l
.add_account("6000", "Costs", AccountType::Expense, None, false)
.unwrap();
// Replayed, as old books may hold them (replay never checks): two
// names alike ignoring case, and one that is another's tagged form.
let names = [
"Café: Rebrand", // 0
"Professional services and consulting fees", // 1
"Professional services and consulting costs", // 2
"Chess set", // 3
"CHESS SET", // 4
"Chess set (3)", // 5
" Kiln ", // 6
];
let first = l.next_class_id();
for (i, n) in names.iter().enumerate() {
l.replay_class(first + i as u32, n.to_string()).unwrap();
}
let id = |i: usize| first + i as u32;
let classes = iif_classes(&l);
let written: Vec<&str> = classes.iter().map(|c| c.written.as_str()).collect();
let cut = "Professional services and consu";
assert_eq!(cut.len(), NAME_MAX);
let tagged = |base: &str, i: usize| {
let suffix = format!(" ({})", id(i));
format!("{}{suffix}", base[..NAME_MAX - suffix.len()].trim_end())
};
assert_eq!(
written,
[
"Cafe- Rebrand".to_string(),
tagged(cut, 1),
tagged(cut, 2),
// Alike "Chess set (3)" only when the first class's id is 3:
// then the tagged one yields again, the books' own name kept.
if id(3) == 3 {
format!("Chess set (3) ({})", id(3))
} else {
format!("Chess set ({})", id(3))
},
format!("CHESS SET ({})", id(4)),
"Chess set (3)".to_string(),
"Kiln".to_string(),
]
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
);
let e = l.create_entry(d(2026, 2, 1), "Glaze");
let m = Money::new(dec("12.00"), Currency::USD);
l.add_line(e, exp, Side::Debit, m).unwrap();
l.add_line(e, cash, Side::Credit, m).unwrap();
l.set_line_class(e, 0, Some(id(0))).unwrap();
l.post_entry(e).unwrap();
let accounts = iif_accounts(&l, |_| QbAccountType::Expense);
let entries = [l.get_entry(e).unwrap()];
let iif = journal_to_iif(&accounts, &classes, &entries).unwrap();
check_iif(&iif.text, &accounts, &entries);
check_iif_classes(&iif.text, &classes, &entries);
let want: Vec<IifChange> = classes
.iter()
.zip(names)
.filter(|(c, n)| c.written != *n)
.map(|(c, n)| IifChange {
field: IifField::ClassName { class: c.id },
books: n.to_string(),
written: c.written.clone(),
})
.collect();
assert_eq!(iif.changes, want);
let lines = iif.warnings();
assert!(lines.contains(&format!(
"class {}'s name \"Café: Rebrand\" is written \"Cafe- Rebrand\"",
id(0)
)));
assert_eq!(lines.len(), want.len(), "{lines:?}");
}
/// iif INVARIANT 9 (review of the P4 follow-ups, finding 4): a name
/// written exactly as the books have it keeps it unless another class's
/// own name is the same ignoring case. "Café" (transliterated onto
/// "Cafe") yields to "Cafe", and its tagged "Cafe (1)" yields again to a
/// class named "Cafe (1)": both kept, the changed one tagged twice. Two
/// own names alike ignoring case both yield; two changed names alike are
/// tagged once each. A changed name keeps yielding as often as it meets
/// an own name (found by `iif_class_names_never_clash`: two "Café"
/// beside "Cafe (1)" and "Cafe (1) (1)" once tagged the last, an own
/// name, for want of a third tag); one a tag can't change (cut to
/// NAME_MAX, ending in its own id) stands, and the own name it meets is
/// tagged instead.
#[test]
fn iif_class_names_written_as_the_books_have_them_are_kept() {
let mut l = Ledger::new(Currency::USD);
for (id, n) in [
(1, "Café"),
(2, "Cafe"),
(3, "Cafe (1)"),
(4, "Kiln"),
(5, "KILN"),
(6, "Ros\u{E9}"),
(7, "Ros\u{E8}"),
] {
l.replay_class(id, n.to_string()).unwrap();
}
let classes = iif_classes(&l);
let written: Vec<&str> = classes.iter().map(|c| c.written.as_str()).collect();
assert_eq!(
written,
[
"Cafe (1) (1)",
"Cafe",
"Cafe (1)",
"Kiln (4)",
"KILN (5)",
"Rose (6)",
"Rose (7)"
]
);
let iif = journal_to_iif(&[], &classes, &[]).unwrap();
check_iif_classes(&iif.text, &classes, &[]);
let written_for = |names: &[String]| {
let mut l = Ledger::new(Currency::USD);
for (i, n) in names.iter().enumerate() {
l.replay_class(i as u32 + 1, n.clone()).unwrap();
}
let classes = iif_classes(&l);
let iif = journal_to_iif(&[], &classes, &[]).unwrap();
check_iif_classes(&iif.text, &classes, &[]);
classes.into_iter().map(|c| c.written).collect::<Vec<_>>()
};
let names = ["Café", "Cafe (1)", "Cafe (1) (1)", "Café"].map(String::from);
assert_eq!(
written_for(&names),
["Cafe (1) (1) (1)", "Cafe (1)", "Cafe (1) (1)", "Cafe (4)"]
);
let x = |n: usize| "X".repeat(n);
let names = [
x(NAME_MAX + 9),
format!("{} (1)", x(NAME_MAX - 4)),
x(NAME_MAX),
];
assert_eq!(
written_for(&names),
[
format!("{} (1)", x(NAME_MAX - 4)),
format!("{} (2)", x(NAME_MAX - 4)),
x(NAME_MAX)
]
);
}
/// iif INVARIANT 9's refusals: a line whose class wasn't declared, two
/// classes written alike (ignoring case), and a class NAME that isn't one
/// level of 1 to NAME_MAX characters (each impossible from iif_classes,
/// so only a list built some other way meets them).
#[test]
fn iif_refuses_an_undeclared_or_unfit_class() {
let (l, id) = class_books();
let accounts = iif_accounts(&l, |_| QbAccountType::Bank);
let entries = [l.get_entry(id).unwrap()];
let rebrand = l.find_class_by_name("Fern & Field rebrand").unwrap().id;
assert_eq!(
journal_to_iif(&accounts, &[], &entries).unwrap_err(),
IifError::UndeclaredClass {
entry: id,
class: rebrand
}
);
let class = |id: u32, written: &str| IifClass {
id,
written: written.to_string(),
name: written.to_string(),
};
assert_eq!(
journal_to_iif(&accounts, &[class(1, "Rebrand"), class(2, "REBRAND")], &[]).unwrap_err(),
IifError::ClassNameCollision {
name: "Rebrand".into(),
classes: vec![1, 2]
}
);
for bad in ["A:B", "", &"x".repeat(NAME_MAX + 1), " Kiln"] {
assert!(
matches!(
journal_to_iif(&accounts, &[class(1, bad)], &[]),
Err(IifError::ClassNameInvalid { class: 1, .. })
),
"{bad:?}"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// iif INVARIANT 9 over random names (old books' duplicates
/// included): every class is written, one level of 1 to NAME_MAX
/// printable ASCII characters, no two alike ignoring case; a plain
/// name is itself (cut to NAME_MAX) or so cut to make room for its
/// " (id)"; the file is accepted, and lists exactly the names written
/// differently.
#[test]
fn iif_class_names_never_clash(names in proptest::collection::vec(prop_oneof![
proptest::sample::select(vec![
"Rebrand", "rebrand", "Rebrand (1)", "Rebrand (2)", "A\u{FF1A}B", "A-B", "北京",
"Bei Jing", "\u{E000}", "\u{E001}", "Café", "Cafe", "Cafe (1)", "Cafe (1) (1)",
"Professional services and consulting fees",
"Professional services and consulting costs",
]).prop_map(String::from),
any_text(),
], 0..10)) {
let mut l = Ledger::new(Currency::USD);
for (i, n) in names.iter().enumerate() {
l.replay_class(i as u32 + 1, n.clone()).unwrap();
}
let classes = iif_classes(&l);
prop_assert_eq!(classes.len(), names.len());
let iif = journal_to_iif(&[], &classes, &[]).unwrap();
check_iif_classes(&iif.text, &classes, &[]);
let mut want = Vec::new();
// A name written as the books have it, no other such name alike.
let exact = |n: &String| {
plain(n) && !n.contains(':') && n == n.trim() && !n.is_empty() && n.len() <= NAME_MAX
};
for (c, n) in classes.iter().zip(&names) {
let tag = format!(" ({})", c.id);
if plain(n) && !n.contains(':') && n == n.trim() && !n.is_empty() {
let mut bases = vec![c.written.as_str()];
while let Some(b) = bases.last().unwrap().strip_suffix(tag.as_str()) {
bases.push(b);
}
prop_assert!(
c.written == n.get(..NAME_MAX).unwrap_or(n).trim_end()
|| (c.written.ends_with(tag.as_str())
&& bases[1..].iter().any(|b| n.starts_with(b))),
"{:?} for {:?}", c.written, n
);
}
if exact(n)
&& names
.iter()
.filter(|m| exact(m) && m.eq_ignore_ascii_case(n))
.count()
== 1
{
prop_assert_eq!(&c.written, n, "an exact name, alone of its kind");
}
if c.written != *n {
want.push(IifChange {
field: IifField::ClassName { class: c.id },
books: n.clone(),
written: c.written.clone(),
});
}
}
prop_assert_eq!(&iif.changes, &want);
}
}
+1 -1
View File
@@ -3,5 +3,5 @@
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in (books served by `wanderbooks mcp`), then tell them where it is. The viewer's "Export for CPA" makes the same package.
- Never file the package, or a file from it, into the books.
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
- Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, the memos grouped); tell the human. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one.
- Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, a project's name as its QuickBooks class, the memos grouped); tell the human. Each line's project is its CLASS, the projects declared first as QuickBooks classes. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one.
- With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, a prepaid contract handed over at its book amount for the CPA to confirm, and the reconciliation from book to tax capital). A line the books can't know (the share of income as figured for tax, always; tax depreciation; a disposal's tax gain; the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in.
+1 -1
View File
@@ -9205,7 +9205,7 @@ impl BooksServer {
let warnings = package.warnings;
if package.iif_changes > 0 {
note.push_str(&format!(
" {} account name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.",
" {} account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.",
package.iif_changes
));
}
+1 -1
View File
@@ -3586,7 +3586,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
assert!(readme.contains(&format!("\n- {w}\n")), "{w} in {readme}");
}
assert!(out["note"].as_str().unwrap().ends_with(
" 5 account name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human."
" 5 account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human."
));
// Receipts: exactly the two behind the rent.
+7 -4
View File
@@ -18,6 +18,8 @@
//! 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;
@@ -29,14 +31,15 @@ use ledger_core::{AccountId, AccountType, Ledger};
/// 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). Returns the file and every text it
/// writes differently from the books.
/// 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, journal_to_iif};
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))
@@ -68,5 +71,5 @@ pub fn export_iif(
.posted_entries()
.filter(|e| e.date() <= as_of && !closes.contains(&e.id()))
.collect();
journal_to_iif(&accounts, &entries).map_err(|e| e.to_string())
journal_to_iif(&accounts, &iif_classes(ledger), &entries).map_err(|e| e.to_string())
}
+8 -2
View File
@@ -576,7 +576,12 @@ pub fn build_package<B: PackageBooks + ?Sized>(
},
iif = {
let mut t = format!(
"general-journal.iif: every posted entry from the first through {as_of_s}, for import into QuickBooks Desktop. Plain ASCII text (its import turns anything else into \"?\"), CR LF line endings; DOCNUM is each entry's id in these books. Period-closing entries are left out: QuickBooks keeps none and works out retained earnings itself, so an imported close would zero its period's profit; trial-balance includes them{}.\n",
"general-journal.iif: every posted entry from the first through {as_of_s}, for import into QuickBooks Desktop. Plain ASCII text (its import turns anything else into \"?\"), CR LF line endings; DOCNUM is each entry's id in these books{}. Period-closing entries are left out: QuickBooks keeps none and works out retained earnings itself, so an imported close would zero its period's profit; trial-balance includes them{}.\n",
if ledger.list_classes().is_empty() {
""
} else {
"; CLASS is each line's project, the projects declared first as QuickBooks classes"
},
if company.members.is_empty() {
""
} else {
@@ -592,9 +597,10 @@ pub fn build_package<B: PackageBooks + ?Sized>(
}
if !warnings.is_empty() {
t.push_str(&format!(
"Written differently in general-journal.iif to fit QuickBooks Desktop (plain ASCII; account names at most {} characters a level, no two alike; a subaccount of another QuickBooks type than its parent's at the top level; account numbers at most {} characters, no two alike; memos at most {} characters); every other file keeps the books' text:\n",
"Written differently in general-journal.iif to fit QuickBooks Desktop (plain ASCII; account names at most {} characters a level, no two alike; a subaccount of another QuickBooks type than its parent's at the top level; account numbers at most {} characters, no two alike; class (project) names at most {} characters, no two alike; memos at most {} characters); every other file keeps the books' text:\n",
ledger_import::NAME_MAX,
ledger_import::NUMBER_MAX,
ledger_import::NAME_MAX,
ledger_import::MEMO_MAX
));
for w in &warnings {
+146 -3
View File
@@ -5,7 +5,8 @@
use chrono::NaiveDate;
use ledger_core::settings::{Company, Member};
use ledger_core::{
AccountType, Currency, Form1099, Ledger, Money, PartyId, Role, Side, TinFacts, TinType,
AccountType, Currency, DocumentLine, Form1099, Ledger, Money, PartyId, Role, Side, TinFacts,
TinType,
};
use ledger_package::{
Package, PackageBooks, PackageRequest, Settings, build_package, vendor_1099_csv,
@@ -1700,6 +1701,131 @@ fn one_list_of_cash_accounts_per_package() {
assert_eq!(text(&plain, "1099-worksheet-2026.csv"), tool(&cash));
}
/// rules.md B-34 rule 8 (P3 cutover review, open point 1): the
/// package's QuickBooks file declares every project as a class and
/// carries each line's project in CLASS; a project name written
/// differently is listed in the warnings and the README, whose IIF line
/// says what CLASS is; books with no project write no class list and the
/// README says nothing of classes.
#[test]
fn the_package_iif_carries_each_lines_project() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
let company = company(false);
let cash = cash();
let before = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(!text(&before, "general-journal.iif").contains("CLASS\tNAME"));
assert!(!text(&before, "README.txt").contains("CLASS"));
let kiln = s.add_class("Kiln: rebuild").unwrap();
s.add_class("Glaze tests").unwrap();
let e = s.create_entry(d("2026-04-03"), "Fire brick").unwrap();
s.add_line(e, id_of(&s, "6000"), Side::Debit, usd("80.00"))
.unwrap();
s.add_line(e, id_of(&s, "1000"), Side::Credit, usd("80.00"))
.unwrap();
s.set_line_class(e, 0, Some(kiln)).unwrap();
s.post_entry(e).unwrap();
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
let iif = text(&p, "general-journal.iif");
let classes = iif.find("!CLASS\tNAME\r\nCLASS\tKiln- rebuild\r\nCLASS\tGlaze tests\r\n");
assert!(
classes.is_some_and(
|at| at < iif.find("!TRNS").unwrap() && at > iif.rfind("\nACCNT\t").unwrap()
),
"{iif}"
);
let line = |acct: &str| {
iif.lines()
.find(|l| l.contains(&format!("\t{e}\t")) && l.contains(acct))
.unwrap()
.to_string()
};
assert!(
line("\tRent\t").contains("\tRent\tKiln- rebuild\t80.00\t"),
"{iif}"
);
assert!(
line("\tChecking\t").contains("\tChecking\t\t-80.00\t"),
"{iif}"
);
let said = format!("class {kiln}'s name \"Kiln: rebuild\" is written \"Kiln- rebuild\"");
assert!(p.warnings.contains(&said), "{:?}", p.warnings);
let readme = text(&p, "README.txt");
assert!(readme.contains(&format!("- {said}\n")), "{readme}");
assert!(
readme.contains(
"; CLASS is each line's project, the projects declared first as QuickBooks classes."
),
"{readme}"
);
}
/// rules.md B-34 rule 8 with B-37 item 8: an invoice's or a bill's
/// lines carry their projects into CLASS through the entry they post,
/// the revenue or expense line of each project tagged, an untagged
/// part and the receivable or payable line left without a class.
#[test]
fn the_package_iif_carries_an_invoices_and_a_bills_projects() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
let kiln = s.add_class("Kiln").unwrap();
let glaze = s.add_class("Glaze tests").unwrap();
let line = |description: &str, amount: &str, class_id| DocumentLine {
description: description.into(),
amount: Decimal::from_str(amount).unwrap(),
class_id,
};
let customer = s.add_customer("Potter").unwrap();
s.create_invoice(
customer,
d("2026-04-10"),
d("2026-05-10"),
vec![
line("Firing", "100.00", Some(kiln)),
line("Studio time", "50.00", None),
],
)
.unwrap();
let vendor = s.add_vendor("Clay Co").unwrap();
s.create_bill(
vendor,
d("2026-04-11"),
d("2026-05-11"),
vec![line("Glaze", "30.00", Some(glaze))],
)
.unwrap();
let company = company(false);
let cash = cash();
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
let iif = text(&p, "general-journal.iif");
assert!(
iif.contains("!CLASS\tNAME\r\nCLASS\tKiln\r\nCLASS\tGlaze tests\r\n"),
"{iif}"
);
let lines = |acct: &str| -> Vec<&str> {
let mut found: Vec<&str> = iif
.lines()
.filter(|l| {
(l.starts_with("TRNS\t") || l.starts_with("SPL\t"))
&& l.contains(&format!("\t{acct}\t"))
})
.collect();
found.sort_unstable();
found
};
let carries = |acct: &str, class: &str, amount: &str| {
lines(acct)
.iter()
.any(|l| l.contains(&format!("\t{acct}\t{class}\t{amount}\t")))
};
assert_eq!(lines("Revenue").len(), 2, "{iif}");
assert!(carries("Revenue", "Kiln", "-100.00"), "{iif}");
assert!(carries("Revenue", "", "-50.00"), "{iif}");
assert!(carries("Accounts Receivable", "", "150.00"), "{iif}");
assert!(carries("General Expenses", "Glaze tests", "30.00"), "{iif}");
assert!(carries("Accounts Payable", "", "-30.00"), "{iif}");
}
/// Plan D-36 (P3, defect 7): general-journal.iif is plain ASCII, and
/// everything it writes differently is in the package's warnings and the
/// README: each account name a line; the memos grouped by what is written
@@ -1754,7 +1880,7 @@ fn the_readme_lists_what_the_iif_writes_differently() {
assert_eq!(p.iif_changes, 1 + 1 + 6, "the name, the lunch, six months");
let readme = text(&p, "README.txt");
let mut block = String::from(
"Written differently in general-journal.iif to fit QuickBooks Desktop (plain ASCII; account names at most 31 characters a level, no two alike; a subaccount of another QuickBooks type than its parent's at the top level; account numbers at most 7 characters, no two alike; memos at most 4095 characters); every other file keeps the books' text:\n",
"Written differently in general-journal.iif to fit QuickBooks Desktop (plain ASCII; account names at most 31 characters a level, no two alike; a subaccount of another QuickBooks type than its parent's at the top level; account numbers at most 7 characters, no two alike; class (project) names at most 31 characters, no two alike; memos at most 4095 characters); every other file keeps the books' text:\n",
);
for w in &want {
block.push_str(&format!("- {w}\n"));
@@ -2023,6 +2149,16 @@ fn the_bookspec_iif_example_matches_the_engine() {
)
.unwrap();
}
// Project N is class N (the example's header).
for (i, p) in spec["setup"]["projects"]
.as_array()
.unwrap()
.iter()
.enumerate()
{
let class = l.add_class(p["name"].as_str().unwrap()).unwrap();
assert_eq!(class, i as u32 + 1, "project N is class N");
}
for (i, e) in spec["events"].as_array().unwrap().iter().enumerate() {
let id = l.create_entry(
d(e["date"].as_str().unwrap()),
@@ -2033,7 +2169,7 @@ fn the_bookspec_iif_example_matches_the_engine() {
i as u64 + 1,
"an event's entry id is its index plus one"
);
for line in e["lines"].as_array().unwrap() {
for (n, line) in e["lines"].as_array().unwrap().iter().enumerate() {
let account = l
.chart()
.find_by_code(line["account"].as_str().unwrap())
@@ -2045,6 +2181,10 @@ fn the_bookspec_iif_example_matches_the_engine() {
};
l.add_line(id, account, side, usd(line["amount"].as_str().unwrap()))
.unwrap();
if let Some(p) = line.get("project") {
let class = l.find_class_by_name(p.as_str().unwrap()).unwrap().id;
l.set_line_class(id, n, Some(class)).unwrap();
}
}
l.post_entry(id).unwrap();
}
@@ -2085,6 +2225,9 @@ fn the_bookspec_iif_example_matches_the_engine() {
ledger_import::IifField::AtTopLevel { code, .. } => {
format!("top level {code}")
}
ledger_import::IifField::ClassName { class } => {
format!("class {class}")
}
};
vec![what, c.books.clone(), c.written.clone()]
})
+20 -10
View File
@@ -1718,10 +1718,19 @@ posting.
level (QuickBooks gives a subaccount its parent's type); each parent
is declared before its subaccounts; an account number past 7
characters, or written like another's, is left out of ACCNUM; a memo
past 4,095 characters is cut. No line names a customer or vendor (no
past 4,095 characters is cut. Each line's class (project) is in
CLASS, every class declared first in a `!CLASS` list (none: no list),
its name written as an account's own name level is (plain ASCII, a
colon '-', at most 31 characters; classes whose names would then match
get " (id)", a name already written differently from the books (made
safe or tagged) yielding first, so a class's own name stands wherever
it can: "Café" beside "Cafe" is written "Cafe (1)" and "Cafe" is
kept, rules.md B-34 rule 8). No line names a customer or vendor (no
NAME column; see the package's README). `warnings` (absent when
there are none) says what the file writes differently from the books,
for the human: each account's name, number and place a line (`account 6010's
for the human: each account's name, number and place and each
class's name a line (`class 2's name "Café rebrand" is written "Cafe
rebrand"`, `account 6010's
name "Café meals" is written "Cafe meals"`, `account number
"12345678" is left out: QuickBooks Desktop holds 7 characters at
most`); the memos grouped, each thing written differently once with
@@ -2176,14 +2185,15 @@ until the owner upgrades them.
**QuickBooks file** (`general-journal.iif`, B-34): as `books.export`
iif makes it, through `as_of`; `warnings` says what it writes
differently from the books, in the same lines as `books.export` iif's
(account names and numbers one each, the memos grouped; every other
file keeps the books' text), the README lists the same lines (and
names the entries with a line on accounts receivable or payable, to
which QuickBooks Desktop may ask the CPA to add a customer or vendor),
and, when the file writes anything differently, `note` ends "N account name(s), number(s), place(s) or memo(s)
are written differently in general-journal.iif to fit QuickBooks
Desktop (warnings says how; so does the README): tell the human.", N
counting each name, number, place and memo.
(account and class names and account numbers one each, the memos
grouped; every other file keeps the books' text), the README lists the
same lines (and names the entries with a line on accounts receivable or
payable, to which QuickBooks Desktop may ask the CPA to add a customer
or vendor), and, when the file writes anything differently, `note` ends
"N account or class name(s), number(s), place(s) or memo(s) are written
differently in general-journal.iif to fit QuickBooks Desktop (warnings
says how; so does the README): tell the human.", N counting each name,
number, place and memo.
**Receipts** (`include_receipts`, default true): `receipts/` holds every
current file behind each posted entry of the fiscal year to date, filed
on it or linked to it (`books.link_attachment`; a linked file counts as
+47 -8
View File
@@ -2134,7 +2134,7 @@ bookspec examples `bookspec/examples/as-of-package-stops-at-as-of.toml`
and `bookspec/examples/1099-worksheet-through-as-of.toml` (`ledger-core`
`tests/bookspec.rs`).
## B-34 The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out
## B-34 The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out, classes carried
**Status:** AUTHORITY (Intuit, `sources/intuit-iif.md`: IIF files are
ASCII text, tab-separated, with no escape; an account is known by its
@@ -2144,9 +2144,16 @@ example file ends every line CR LF; QuickBooks Desktop holds an account
name of 31 characters, an account number of 7 and a memo of 4,095; "The
parent and subaccount must be the same account type"; a journal entry
to accounts receivable or payable needs a customer or vendor, and takes
one such account a transaction); CONVENTION (a QuickBooks integrator and
an IIF importer, same file: a longer name is cut on a chart import but not
in a journal's lines; a double quote is a text delimiter); DECISION (owner, 2026-10-20, plan D-36: plain ASCII, what isn't
one such account a transaction; a class list's keyword is CLASS, "Only
the NAME column is required", a subclass's NAME its parents' first,
joined by colons, and the kit declares its classes before the
transactions that name them, line by line in CLASS; Intuit's qbXML
Onscreen Reference, `ClassAdd`: a class's Name at most 31 characters in
QuickBooks Desktop, as an account's); CONVENTION (a
QuickBooks integrator and an IIF importer, same file: a longer name is
cut on a chart import but not in a journal's lines; a double quote is a
text delimiter; Conductor's QuickBooks Desktop API: a class's name
case-insensitive, so no two alike ignoring case); DECISION (owner, 2026-10-20, plan D-36: plain ASCII, what isn't
ASCII transliterated and listed; CR LF; DOCNUM the entry's id; closing
entries left out; every entry since the company began until the CPA
says otherwise); Unicode Standard Annex #14 (the mandatory line breaks)
@@ -2223,9 +2230,35 @@ says otherwise); Unicode Standard Annex #14 (the mandatory line breaks)
across them. So the file names none, and the package README names the
entries with a line on an A/R or A/P account (`Iif::receivable_payable`),
so the CPA can add the name if QuickBooks asks.
8. **Each line's class (project, B-37) is its CLASS**, and every class in
the books is declared in a `!CLASS` list (NAME) after the accounts and
before the transactions, as the kit declares its lists; books with no
class write no list. A class's NAME is written as an account's own
name is (rules 3 and 4: plain ASCII, a ':' a '-' since a colon starts
a subclass, trimmed, '?' if nothing is left, cut to 31 characters);
classes whose NAMEs would match ignoring case get " (id)" after their
names (the name cut to make room), round after round until none does,
as accounts get " (code)", except that among names that match, those
already written differently from the books (made safe, or tagged)
yield first, another " (id)" each time need be (CONVENTION: the CPA sees
the books' own names wherever they can stand): a class's name
written exactly as the books have it is kept unless another class's
own name is the same ignoring case (classes 1 "Café", 2 "Cafe" and 3
"Cafe (1)": "Cafe (1) (1)", the other two kept; review of the P4 follow-ups, finding 4,
2026-10-22: before, all three were renamed; and a fourth "Cafe (1) (1)"
makes class 1 "Cafe (1) (1) (1)", where a cap of two tags once renamed
class 4 instead). A tag that would change nothing (a name cut to 31
characters, ending in its own " (id)") isn't made: the name it meets
is tagged instead. Each class name written differently is
listed with rule 6's (`class 2's name "Café rebrand" is written "Cafe
rebrand"`), and the package README's "written differently" paragraph
says class names are held to 31 characters, no two alike. A line
whose class isn't declared is refused, never written classless. Before
2026-10-22 the file left CLASS empty on every line and the README
didn't say so (the P3 cutover review's open point 1).
**Enforced:** `ledger_import::iif` (`field`, `iif_accounts`,
`journal_to_iif`, `Iif::warnings`), `ledger_package::export_iif` and
`iif_classes`, `journal_to_iif`, `Iif::warnings`), `ledger_package::export_iif` and
`build_package`, `ledger-mcp` `books.export` and
`books.export_cpa_package`.
**Checked:** `ledger-import` `iif.rs` unit tests (every one of the
@@ -2240,12 +2273,18 @@ pinned); `tests/spec_import.rs` (`iif_docnum_is_the_entry_id`,
`iif_warnings_group_the_memos_by_what_is_written_differently`,
`iif_warnings_never_hold_a_tin`,
`iif_change_warnings_are_one_line_each`,
`iif_keeps_a_tin_readable_to_the_package_scan`, the properties
`iif_round_trips_random_books` and
`iif_names_never_clash_and_every_change_is_listed`); `ledger-package`
`iif_keeps_a_tin_readable_to_the_package_scan`,
`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`, the properties
`iif_round_trips_random_books`,
`iif_names_never_clash_and_every_change_is_listed` and
`iif_class_names_never_clash`); `ledger-package`
`tests/package.rs` (`iif_leaves_out_period_closes`,
`the_readme_lists_what_the_iif_writes_differently`,
`the_readme_names_the_entries_on_receivables_and_payables`,
`the_package_iif_carries_each_lines_project`,
`the_bookspec_iif_example_matches_the_engine`, and B-33's); the bookspec
example `bookspec/examples/iif-quickbooks-journal-file.toml` (formats
appendix: the file line by line and its changes, derived by hand);
+48
View File
@@ -79,6 +79,31 @@ SHA-256 of `IIF Import Kit/IIF Header Help/!ACCNT.htm`: `91b5cbcc90b3b251b467448
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 Header Help: Class list (CLASS)
Retrieved: 2026-10-22 (the kit above, unchanged: same SHA-256)
SHA-256 of `IIF Import Kit/IIF Header Help/!CLASS.htm`: `de8a89aac069946583ed1fec7b9042162343b5b34146fb456d17851bccca0ff8`
Cited (not quoted, but for the lines below): the Class list's keyword is CLASS; of its columns (NAME, and TIMESTAMP and REFNUM in export files only) the page says:
> Only the NAME column is required.
> If the class is a subclass of another class, the name includes the name of the parent classes, beginning with the highest level class. If you are creating an import file, use a colon (:) to separate subclass names.
So a class's NAME, like an account's, is one level per colon: the books, whose classes have no parents, write a colon in a class's name as '-', as in an account's (rules.md B-34 rule 8). The page states no length limit and nothing about case.
### IIF Example Files: list declarations
SHA-256 of `IIF Import Kit/IIF Example Files/QB 2007 & Enterprise 7.0 and later/Import First - List Declarations (Not Version Specific).iif` (the same bytes in each version's folder): `13c730b23bcb8c142621b04206e3628241d83051442c9b14ea2416d21163439c`
Cited (not quoted): a file named "Import First", declaring the lists (accounts, classes, names) that the transaction examples use; its class rows, transcribed (each `→` one tab; the trailing empty fields left out):
```text
!CLASS→NAME→REFNUM→TIMESTAMP→HIDDEN→DELCOUNT
CLASS→Class Name→2→1154540261→N→0
CLASS→Owner→1→1154540254→N→0
```
The kit's `2004 Deposit (Income).iif` (`IIF Example Files/QB 2004 & Enterprise 4.0/Deposit/`) (SHA-256 `82a5d0efbdeb9440a81e48fa1675355e37d2d78650a026c87caa640d0955ffdf`) names the declared class "Class Name" in the CLASS column of its SPL line, its TRNS line's left empty: a line's class is the one it names, per line. So a class is declared in a `!CLASS` list before the transactions that name it, as accounts are (the Import Manual and `journal.zip`'s read-me, above and below: lists first, or default types are assumed). The books write the one required column, NAME.
### 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`
@@ -157,6 +182,29 @@ Not Intuit, for the record: a third-party IIF vendor answering on Intuit's commu
No Intuit source says what an IIF journal import does with an A/R or A/P line that has no NAME (refuses it, imports it, or asks). So the books write no NAME (writing names would need the customer and vendor lists declared too, with a name unique across them) and the package README names the entries with an A/R or A/P line, so the CPA can add the name in QuickBooks if it asks (rules.md B-34 rule 7).
## How long a class's name may be
Searched 2026-10-22. Intuit's limits article (above) has no row for a class; its `!CLASS` header help states none. Intuit's .NET SDK reference gives QuickBooks Desktop ("QBW") 31 characters for the names of its other lists (`Account.Name`, above; `Item.Name`, `Term.Name`, `JobType.Name`, `CustomerType.Name`), but for a class its only page, `Department.Name` (<https://static.developer.intuit.com/sdkdocs/qbv3doc/ippdotnetdevkitv3/html/d658b447-b3f9-26f1-3a65-e213cfe3c379.htm>, retrieved 2026-10-22, SHA-256 of the retrieved page `9a33e3cbed97cea28558c8530b6d4950b54346ab8f8581897f1a333b072b026d`), describes "User recognizable name for the Class" with "QBW: 1024": a page for departments (a QuickBooks Online list) whose wording names classes, so not taken as Desktop's class limit.
### Intuit's qbXML Onscreen Reference: `ClassAdd`
Source: the QuickBooks Desktop SDK's Onscreen Reference (OSR), <https://static.developer.intuit.com/qbSDK-current/common/newosr/index.html>, whose `ClassAdd` request page is drawn from <https://static.developer.intuit.com/qbSDK-current/common/newosr/qbsdk/json/ClassAddRq.json>
Retrieved: 2026-10-22
SHA-256 of the retrieved `ClassAddRq.json`: `760505a11638ccdb3ea30a9714fa02651509660291ae4eff09e20d4942a310de`
The request's `Name` element (required, a string, `"xmlNameHtml" : "Class-Name.html"`) carries the OSR's length limits per edition:
> "maxCA" : 31, ... "maxUK" : 31, "maxAU" : 31, ... "maxDT" : 31, ... "maxOE" : 100
So 31 characters for a class's name in every QuickBooks Desktop edition the OSR covers (the US desktop, Canada, the UK and Australia), 100 only in Online Edition ("OE"). The same reference's `AccountAddRq.json` (retrieved the same day, SHA-256 `856c64ea3aeea8229692f98cdcfe32278cc9070c56bff7eed74d9e53702c3a40`) gives an account's `Name` the same figures (31 in each Desktop edition, "maxOE" : 100), matching the limits article's Account Name 31 and the .NET reference's "QBW: Max=31", so the fields read as Desktop's own limits.
Not Intuit, for the record: Conductor's QuickBooks Desktop API reference (a service over QuickBooks Desktop's own SDK, which mirrors the OSR), "Create a class", <https://docs.conductor.is/api-ref/qbd/classes/create.md>, retrieved 2026-10-22, SHA-256 of the retrieved file `8588c0ffaa7d5082abd0329734a00ee42179894109ec7572a66d7f844e908d89`, of a class's `name`:
> The case-insensitive name of this class. Not guaranteed to be unique because it does not include the names of its hierarchical parent objects like `fullName` does.
> Maximum length: 31 characters.
So the books hold a class's NAME to 31 characters, as an account's own level (Intuit's own limit for a class, above), and keep no two alike ignoring case (Conductor: case-insensitive; rules.md B-34 rule 8).
## What QuickBooks does with an over-long name (not Intuit)
Source: Thomson Reuters, Accounting CS help, "Export payroll journal entries", <https://thomsonreuters.com/en-us/help/accounting-cs/integrate-data-with-other-applications/export-payroll-journal-entries>
Retrieved: 2026-10-20
+40
View File
@@ -2138,6 +2138,46 @@ order, the `contains` test); `add_document_parts`' tag index;
kind test; `document_projects_rule`'s branch; the viewer's
`document_of` (invoice before bill).
## After P3: the cutover review's open points 1 and 2 (2026-10-22)
The P3 cutover review's open points 1 (the IIF left CLASS empty) and 2
(a state filing fee read as cash paid to suppliers). Point 3 (ties in
the tax-basis member split) waits for the owner: nothing is posted
before the year-end close. No mutation run (D-33).
**Point 1, the IIF's classes** (rules.md B-34 rule 8): `ledger-import`
`tests/spec_import.rs`
`iif_writes_each_lines_class_and_declares_the_classes_first` (the file
exact: the `!CLASS` list after the accounts, before `!TRNS`, every class
declared, each line's class in CLASS, empty where it has none; no list
without classes),
`iif_class_names_fit_quickbooks_and_every_change_is_listed` (a colon and
an accent, a name past 31 characters, two alike once cut, two alike
ignoring case, one another's tagged form, a padded name: each written
form exact, every change listed and said),
`iif_class_names_written_as_the_books_have_them_are_kept` (review of
the follow-ups, finding 4: "Café" beside "Cafe" and "Cafe (1)" is
tagged twice and the two own names kept; own names alike, or changed
names alike, each tagged once; with "Cafe (1) (1)" too, tagged a third
time; a name a tag can't change, cut and ending in its own id, stands
and the own name it meets is tagged),
`iif_refuses_an_undeclared_or_unfit_class`, and the property
`iif_class_names_never_clash` (256 cases of random and clashing names,
old books' duplicates included, "Café" and its twins among them; a name
written as the books have it, no other own name alike, is kept); `check_iif` now takes CLASS lines and
`check_iif_classes` checks every TRNS and SPL line's class. `ledger-package`
`tests/package.rs` `the_package_iif_carries_each_lines_project` (the
package's file, warnings and README) and the bookspec example
`iif-quickbooks-journal-file.toml`, now with two projects (one carried,
one only declared). Broken to confirm: writing CLASS empty again fails
the two exact-file tests, the package test and the bookspec example;
dropping the " (id)" tag fails the names test and the property;
tagging every name that meets (no yielding) fails the kept-names test
and the property. The property found (seed in
`spec_import.proptest-regressions`) that a cap of two tags made an own
name yield to a changed one ("Café" twice beside "Cafe (1)" and "Cafe (1)
(1)"); the cap is gone, a tag that changes nothing is skipped instead.
## Benchmarks (baseline 2026-10-13)
No budget, by decision (the owner, 2026-10-13: the books do little work
+15 -3
View File
@@ -188,11 +188,23 @@ its invoices. The handbook's caveat is gone for books with the feature.
`projects` but not it, so until the owner's next format upgrade
(`wanderbooks books upgrade`, D-39) their invoices and bills still take
none (the handbook says so).
- Raised by the cutover review (private change log, "Open points"): the
IIF leaves its CLASS field empty although entries carry projects; a
- Raised by the cutover review (private change log, "Open points"): a
state filing fee is classed as cash paid to suppliers where ASC
230-10-45-17(c) suggests other operating payments; ties in the per-entry
member split all go to the first member.
member split all go to the first member (the owner, 2026-10-22: nothing
is posted until the year-end close, three weeks off, so it waits).
Its first point is done (2026-10-22, B-34 rule 8): the IIF writes each
line's project in CLASS, the projects declared first in a `!CLASS`
list, their names held to QuickBooks' 31 characters, no two alike, and
listed in the README when written differently (tests
`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`, the property
`iif_class_names_never_clash`, `the_package_iif_carries_each_lines_project`;
bookspec `iif-quickbooks-journal-file`). The first company's
package changes at its next build: its project-tagged lines carry
the class, and the class list is new.
- The viewer recomputes a long-life asset's whole schedule every frame
(`ledger-gui` `pages/assets.rs` `schedule`, called from the asset
page's draw; defect 22's note: slow, never wrong).