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
@@ -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
@@ -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\
|
||||
|
||||
@@ -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é"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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