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

+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