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:
17 files changed
+1026
-89
No files matched your search
@@ -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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()]
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user