P3: the IIF in plain ASCII with every change listed, and the CPA package as of one day (defects 7, 21)

The CPA package's general-journal.iif now holds every posted entry through
as_of (closing entries left out, D-36), written as plain ASCII with CR LF,
DOCNUM the entry id, QuickBooks Desktop's field limits and subaccount types
honoured, and every character written differently listed in the export's
warnings and the README (grouped by substitution, TINs masked). The 1099
worksheet stops at as_of too. PDFs print CJK, Arabic, Hebrew, Thai and
Devanagari (Noto fallbacks, OFL), and so does the viewer. has_full_tin reads
Unicode separators.

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-21 09:35:32 -06:00
1 parent 1e8f9275b9
commit 2c29d48ab9
48 files changed
+5661 -317

No files matched your search

+2
View File
@@ -19,4 +19,6 @@ zip = "9.0.1"
workspace = true
[dev-dependencies]
proptest = "1"
tempfile = "3"
toml = "1"
+34 -15
View File
@@ -2,29 +2,44 @@
//! and the package's `general-journal.iif`.
//!
//! ```text
//! GOAL: the CPA imports every posted entry into QuickBooks Desktop, each
//! account declared with the type QuickBooks needs.
//! SOURCE: ledger_import::iif (the IIF format and account types).
//! GOAL: the CPA imports the books through a day into QuickBooks Desktop,
//! each account declared with the type QuickBooks needs, and gets
//! the balances and the profit of the package's statements.
//! SOURCE: ledger_import::iif (the IIF format and account types); rules.md
//! B-33 (as of a day), B-34; DECISION (owner, 2026-10-20, plan
//! D-36: closing entries are left out).
//! INVARIANTS:
//! 1. The cash accounts are BANK; the receivable and payable control
//! accounts AR and AP; a registered asset's cost and accumulated
//! depreciation FIXASSET; every other account follows its type.
//! 2. Every posted entry, and only those.
//! accounts AR and AP; the cost and accumulated depreciation accounts
//! of an asset bought on or before as_of FIXASSET; every other
//! account follows its type. Every account in the chart is declared.
//! 2. Every posted entry dated on or before as_of, from the first, in
//! the order posted, except period closes: QuickBooks keeps no
//! closing entries (it works out retained earnings itself), so an
//! imported close would zero its period's profit. The file's
//! balances are the trial balance as of as_of without the closes.
//! ```
use chrono::NaiveDate;
use ledger_core::{AccountId, AccountType, Ledger};
/// Render the journal as IIF text via ledger-import, declaring every
/// account with its QuickBooks type: the cash accounts (`cash_codes`, the
/// books' `cash_accounts`) are BANK, the receivable and payable control
/// accounts AR and AP, a registered asset's cost and accumulated
/// depreciation FIXASSET, and the rest follow their wanderbooks type
/// (other current asset, other current liability, equity, income,
/// expense).
pub fn export_iif(ledger: &Ledger, cash_codes: &[String]) -> Result<String, String> {
/// The journal through `as_of` as IIF, via ledger-import (INVARIANTS 1-2),
/// declaring every account with its QuickBooks type: the cash accounts
/// (`cash_codes`, the books' `cash_accounts`) are BANK, the receivable and
/// payable control accounts AR and AP, the cost and accumulated
/// depreciation accounts of an asset bought by `as_of` FIXASSET, and the
/// rest follow their wanderbooks type (other current asset, other current
/// liability, equity, income, expense). 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};
let fixed: std::collections::HashSet<AccountId> = ledger
.registered_assets()
.filter(|a| a.purchase_date <= as_of)
.flat_map(|a| [a.cost_account, a.accum_depr_account])
.collect();
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
@@ -48,6 +63,10 @@ pub fn export_iif(ledger: &Ledger, cash_codes: &[String]) -> Result<String, Stri
}
}
});
let entries: Vec<_> = ledger.posted_entries().collect();
let closes = ledger.closing_entry_ids();
let entries: Vec<_> = ledger
.posted_entries()
.filter(|e| e.date() <= as_of && !closes.contains(&e.id()))
.collect();
journal_to_iif(&accounts, &entries).map_err(|e| e.to_string())
}
+102 -21
View File
@@ -6,16 +6,18 @@
//! GOAL: everything the CPA needs for the return in one zip, the K-1s'
//! and 1099s' TINs included when asked, built the same by the
//! server and the viewer.
//! SOURCE: docs/mcp-interface.md; B-26; DECISION (owner, 2026-10-15):
//! "it needs to be in the damn CPA package."
//! SOURCE: docs/mcp-interface.md; B-26; B-33 (as of one day); B-34 (the
//! IIF); DECISION (owner, 2026-10-15): "it needs to be in the damn
//! CPA package."
//! INVARIANTS:
//! 1. The statements (profit and loss, balance sheet, cash flow, members'
//! equity with members, tax summary, trial balance) each as PDF and
//! XLSX, for the fiscal year containing as_of, through as_of; the
//! fixed asset register when there are fixed assets; the general
//! ledger and journal (XLSX); the journal as IIF; the 1099 worksheet
//! for as_of's calendar year (or why it can't be made); with members,
//! the K-1 worksheet; a README first.
//! fixed asset register when an asset was bought by as_of; the
//! general ledger and journal (XLSX); the journal through as_of as
//! IIF; the 1099 worksheet for as_of's calendar year through as_of
//! (or why it can't be made); with members, the K-1 worksheet; a
//! README first.
//! 2. With members, k1-members.csv has one row per member: name, party,
//! share, capital account, TIN and type (or last four without a TIN
//! source), and the K-1 worksheet's capital figures (ledger_core::k1)
@@ -32,6 +34,20 @@
//! Withdrawn files never.
//! 5. Read-only (crate INVARIANT 1): the package is returned, its files
//! and the zip, and written nowhere.
//! 6. As of one day (B-33): no figure, entry, receipt or worksheet row
//! reflects anything dated after as_of; the chart and documents/ are
//! as they stand when the package is made.
//! 7. One list of cash accounts and one of financing accounts per
//! package: the request's, else the settings'. The cash flow, the K-1
//! worksheet, the IIF's BANK accounts and the 1099 worksheet's
//! payments all read the same.
//! 8. Every account name, number or place and every memo
//! general-journal.iif writes differently from the books (it holds
//! plain ASCII, within QuickBooks Desktop's limits and its rule that
//! a subaccount has its parent's type: B-34) is in `warnings` and the
//! README, the memos grouped by what is written differently; the
//! README also names the entries with a line on accounts receivable
//! or payable, which the file gives no customer or vendor.
//! ```
use chrono::NaiveDate;
@@ -84,6 +100,14 @@ pub struct Package {
pub tin_gaps: Vec<String>,
/// The receipts in receipts/ (one per entry they are behind).
pub receipt_count: usize,
/// What general-journal.iif writes differently from the books to fit
/// QuickBooks Desktop (INVARIANT 8), as `ledger_import::Iif::warnings`
/// says it: each account name and number a line, each thing written
/// differently in the memos a line with the entries it is in.
pub warnings: Vec<String>,
/// How many account names, numbers and memos general-journal.iif
/// writes differently (each one the warnings cover).
pub iif_changes: usize,
}
/// The package's file name for `as_of`.
@@ -112,6 +136,17 @@ pub fn build_package<B: PackageBooks + ?Sized>(
let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?;
let members = report_members(company);
// One list of each per package (INVARIANT 7).
let cash_codes = if req.cash_accounts.is_empty() {
settings.cash_accounts
} else {
req.cash_accounts
};
let financing_codes = if req.financing_accounts.is_empty() {
settings.financing_accounts
} else {
req.financing_accounts
};
// The financial statements, each as PDF and XLSX.
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
@@ -158,11 +193,6 @@ pub fn build_package<B: PackageBooks + ?Sized>(
statements.push(("members-equity", report::members_equity(&me)));
// The K-1 worksheet: each member's capital account for the
// year (Schedule K-1 Item L) and their TIN (INVARIANT 2).
let cash_codes = if req.cash_accounts.is_empty() {
settings.cash_accounts
} else {
req.cash_accounts
};
let cash: Vec<AccountId> = cash_codes
.iter()
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
@@ -312,8 +342,9 @@ pub fn build_package<B: PackageBooks + ?Sized>(
files.push((format!("{name}.pdf"), pdf(st)?));
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
}
// The fixed asset register, when there are fixed assets.
if ledger.registered_assets().next().is_some() {
// The fixed asset register, when an asset was bought by as_of (one
// bought later is not in it, so neither is an empty register).
if ledger.registered_assets().any(|a| a.purchase_date <= as_of) {
let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?;
files.push((
"fixed-asset-register.pdf".into(),
@@ -334,10 +365,17 @@ pub fn build_package<B: PackageBooks + ?Sized>(
"general-journal.xlsx".into(),
xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?,
));
// 1099 worksheet for the calendar year of as_of.
// 1099 worksheet for the calendar year of as_of, through as_of
// (INVARIANT 6): the payments from January 1, out of the package's
// cash and financing accounts (INVARIANT 7).
let year = chrono::Datelike::year(&as_of);
let mut worksheet_1099 = false;
match vendor_1099_report(ledger, settings, year, None) {
let payments = Settings {
cash_accounts: cash_codes,
financing_accounts: financing_codes,
..*settings
};
match vendor_1099_report(ledger, &payments, year, Some(as_of), None) {
Ok(r1099) => {
let mut w = String::from("vendor,form,w9_on_file,paid,reportable");
if tins.is_some() {
@@ -516,13 +554,20 @@ pub fn build_package<B: PackageBooks + ?Sized>(
files.push(k1);
}
let seq = books.journal_seq();
files.push((
"general-journal.iif".into(),
export_iif(ledger, settings.cash_accounts)?.into_bytes(),
));
// The journal through as_of for QuickBooks, closes left out, and what
// it writes differently (INVARIANT 8).
let iif = export_iif(ledger, cash_codes, as_of)?;
let (warnings, iif_changes) = (iif.warnings(), iif.changes.len());
let receivable_payable: Vec<String> = iif
.receivable_payable
.iter()
.map(ToString::to_string)
.collect();
files.push(("general-journal.iif".into(), iif.text.into_bytes()));
let year_end = NaiveDate::from_ymd_opt(year, 12, 31) == Some(as_of);
let include_tins = tins.is_some();
let readme = format!(
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\n{iif}{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
name = company.name,
basis = company.basis,
members = if company.members.is_empty() {
@@ -530,9 +575,43 @@ pub fn build_package<B: PackageBooks + ?Sized>(
} else {
"members' equity, "
},
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",
if company.members.is_empty() {
""
} else {
", and the members' equity statement and the K-1 worksheet show each member's share"
}
);
if !receivable_payable.is_empty() {
t.push_str(&format!(
"It names no customer or vendor on its lines to accounts receivable or payable, in {} {}: QuickBooks Desktop asks for one on a journal entry to A/R or A/P (and takes only one A/R or A/P account a transaction); add it there if QuickBooks asks.\n",
if receivable_payable.len() == 1 { "entry" } else { "entries" },
receivable_payable.join(", ")
));
}
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",
ledger_import::NAME_MAX,
ledger_import::NUMBER_MAX,
ledger_import::MEMO_MAX
));
for w in &warnings {
t.push_str(&format!("- {w}\n"));
}
}
t
},
worksheet = if worksheet_1099 {
format!(
"1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold{}.\n",
"1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099{}, against the year's threshold{}.\n",
if year_end {
String::new()
} else {
format!(" from January 1 through {as_of_s}")
},
if include_tins {
", with each payee's TIN"
} else {
@@ -623,5 +702,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
zip: zip_buf,
tin_gaps,
receipt_count,
warnings,
iif_changes,
})
}
+7 -2
View File
@@ -45,16 +45,20 @@ pub fn payment_accounts(ledger: &Ledger, settings: &Settings<'_>) -> ledger_core
/// The 1099 report for `year`, the one every tool, the worksheet, and the
/// CPA package use: payments out of [`payment_accounts`], an entry's
/// shares resolved with the settings' members.
/// shares resolved with the settings' members. Given `through`, only the
/// payments from January 1 through it (the CPA package as of a day,
/// rules.md B-33).
pub fn vendor_1099_report(
ledger: &Ledger,
settings: &Settings<'_>,
year: i32,
through: Option<chrono::NaiveDate>,
threshold: Option<Decimal>,
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
ledger_core::vendor_1099_report_with_members(
ledger,
year,
through,
threshold,
&payment_accounts(ledger, settings),
&settings.company.party_members(ledger),
@@ -128,7 +132,8 @@ pub fn vendor_1099_worksheet(
) -> Result<Vendor1099Worksheet, String> {
// A year past what wanderbooks knows is refused with guidance
// (research or ask, pass `threshold`, then update the rule).
let r = vendor_1099_report(ledger, settings, year, threshold).map_err(|e| e.to_string())?;
let r =
vendor_1099_report(ledger, settings, year, None, threshold).map_err(|e| e.to_string())?;
let missing_w9 = r
.vendors
.iter()
+962 -3
View File
@@ -4,13 +4,16 @@
use chrono::NaiveDate;
use ledger_core::settings::{Company, Member};
use ledger_core::{AccountType, Currency, Form1099, Money, PartyId, Role, Side, TinFacts, TinType};
use ledger_core::{
AccountType, Currency, Form1099, Ledger, Money, PartyId, Role, Side, TinFacts, TinType,
};
use ledger_package::{
Package, PackageBooks, PackageRequest, Settings, build_package, vendor_1099_csv,
vendor_1099_csv_name, vendor_1099_worksheet,
};
use ledger_store::{Snapshot, Store, Subject};
use rust_decimal::Decimal;
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::path::Path;
use std::str::FromStr;
@@ -366,8 +369,19 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
assert!(!names(&p).contains(&"k1-members.csv"));
let iif = text(&p, "general-journal.iif");
assert!(iif.contains("Checking") && iif.contains("BANK"), "{iif}");
// Every posted entry, the year after as_of's included.
assert!(iif.contains("Next year's rent"), "{iif}");
// Every posted entry through as_of; the next year's rent is not.
assert!(!iif.contains("Next year's rent"), "{iif}");
// The rent's memo, `Rent, "March"`: its double quotes written single
// (QuickBooks reads one as a text delimiter), and said.
assert!(iif.contains("\tRent, 'March'\r\n"), "{iif}");
assert_eq!(
p.warnings,
[format!(
"\"\\\"\" is written \"'\" in the memo of entry {}",
b.rent
)]
);
assert_eq!(p.iif_changes, 1);
assert_zip_is_the_files(&p);
}
@@ -988,3 +1002,948 @@ fn a_receipts_index_amount_is_the_entrys_debit_total_as_written() {
assert_eq!(fields[4], "100.00", "{row}");
assert_eq!(fields[5], format!("receipts/2026-06-01-entry-{e}-june.pdf"));
}
// ---------------------------------------------------------------------------
// As of one day (rules.md B-33), and the QuickBooks file (B-34)
// ---------------------------------------------------------------------------
/// The IIF's transactions in file order: each one's DOCNUM, memo, and
/// lines as (account NAME, signed amount).
type IifTxn = (u64, String, Vec<(String, Decimal)>);
fn iif_txns(iif: &str) -> Vec<IifTxn> {
let mut out: Vec<IifTxn> = Vec::new();
for l in iif.split("\r\n") {
let f: Vec<&str> = l.split('\t').collect();
let line = || (f[4].to_string(), Decimal::from_str(f[6]).unwrap());
match f[0] {
"TRNS" => out.push((f[7].parse().unwrap(), f[8].to_string(), vec![line()])),
"SPL" => out.last_mut().unwrap().2.push(line()),
_ => {}
}
}
out
}
/// Each account's balance in the IIF, debit positive, by ACCNUM; zero
/// balances left out.
fn iif_balances(iif: &str) -> BTreeMap<String, Decimal> {
let number: HashMap<String, String> = iif
.split("\r\n")
.map(|l| l.split('\t').collect::<Vec<_>>())
.filter(|f| f[0] == "ACCNT")
.map(|f| (f[1].to_string(), f[3].to_string()))
.collect();
let mut out: BTreeMap<String, Decimal> = BTreeMap::new();
for (_, _, lines) in iif_txns(iif) {
for (name, a) in lines {
*out.entry(number[&name].clone()).or_default() += a;
}
}
out.retain(|_, v| !v.is_zero());
out
}
/// The trial balance as of `as_of` (ledger_core::reports), debit
/// positive, by code, without the closing entries' lines; zero balances
/// left out.
fn tb_without_closes(ledger: &Ledger, as_of: NaiveDate) -> BTreeMap<String, Decimal> {
let mut out: BTreeMap<String, Decimal> =
ledger_core::reports::trial_balance_as_of(ledger, as_of)
.lines
.into_iter()
.map(|l| (l.code, l.debit - l.credit))
.collect();
for id in ledger.closing_entry_ids() {
let e = ledger.get_entry(id).unwrap();
if e.date() > as_of {
continue;
}
for l in e.lines() {
let code = ledger.chart().get(l.account).unwrap().code.clone();
let signed = match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
};
*out.entry(code).or_default() -= signed;
}
}
out.retain(|_, v| !v.is_zero());
out
}
/// Defect 21 (P3; found in P0, 2026-10-16): the package's
/// general-journal.iif held every posted entry, the ones after as_of too,
/// while every other file stopped at as_of. Now it holds every posted
/// entry from the first through as_of, each with its id as DOCNUM, and its
/// balances are the trial balance as of as_of.
#[test]
fn as_of_package_iif_stops_at_as_of() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let company = company(false);
let cash = cash();
let p = build_package(&b.store, &settings(&company, &cash), &request(false, None)).unwrap();
let iif = text(&p, "general-journal.iif");
let ledger = b.store.ledger();
let as_of = d("2026-12-31");
let through: Vec<u64> = ledger
.posted_entries()
.filter(|e| e.date() <= as_of)
.map(|e| e.id())
.collect();
assert_eq!(
through.len(),
4,
"the year's four entries; next year's rent is the fifth"
);
let txns = iif_txns(iif);
assert_eq!(txns.iter().map(|t| t.0).collect::<Vec<_>>(), through);
assert!(txns.iter().all(|t| t.1 != "Next year's rent"), "{iif}");
assert_eq!(iif_balances(iif), tb_without_closes(ledger, as_of));
assert!(text(&p, "README.txt").contains(
"general-journal.iif: every posted entry from the first through 2026-12-31, 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."
));
}
/// B-33 at its edges, through ledger_package::export_iif (the package's
/// own): an entry dated as_of is in and one dated the day after is not; a
/// reversal after as_of leaves its original in, under the original's id;
/// as of a day before the first entry the file declares the accounts and
/// holds no transaction.
#[test]
fn the_iif_through_as_of_at_its_edges() {
let dir = tempfile::tempdir().unwrap();
let (mut s, rent) = plain_books(dir.path());
let fee = post(
&mut s,
"2026-03-31",
"Bank fee",
&[
("6000", Side::Debit, "5.00"),
("1000", Side::Credit, "5.00"),
],
);
let reversal = s
.reverse_entry(rent, d("2026-04-01"), "Rent refunded")
.unwrap();
let ledger = s.ledger();
let cash = cash();
let ids = |as_of: &str| -> Vec<u64> {
let iif = ledger_package::export_iif(ledger, &cash, d(as_of)).unwrap();
assert!(iif.changes.is_empty());
assert_eq!(iif_balances(&iif.text), tb_without_closes(ledger, d(as_of)));
iif_txns(&iif.text).iter().map(|t| t.0).collect()
};
assert_eq!(ids("2026-02-28"), Vec::<u64>::new());
assert_eq!(ids("2026-03-01"), [rent]);
assert_eq!(ids("2026-03-30"), [rent]);
assert_eq!(ids("2026-03-31"), [rent, fee]);
assert_eq!(ids("2026-04-01"), [rent, fee, reversal]);
// Before the first entry: every account, no transaction.
let empty = ledger_package::export_iif(ledger, &cash, d("2026-02-28")).unwrap();
let accnt = empty
.text
.split("\r\n")
.filter(|l| l.starts_with("ACCNT\t"))
.count();
assert_eq!(accnt, ledger.chart().len());
assert!(!empty.text.contains("\r\nTRNS\t"));
assert!(empty.text.ends_with("!ENDTRNS\r\n"));
}
/// The books as the package reads them, the journal's length fixed: it is
/// the one thing later activity rightly changes in a package as of an
/// earlier day (every report's footer and the README name it).
struct AtSeq<'a>(&'a Store, u64);
impl PackageBooks for AtSeq<'_> {
fn ledger(&self) -> &Ledger {
PackageBooks::ledger(self.0)
}
fn journal_seq(&self) -> u64 {
self.1
}
fn all_attachments(&self) -> Result<Vec<ledger_store::Attachment>, ledger_store::StoreError> {
PackageBooks::all_attachments(self.0)
}
fn links_of(&self, id: u64) -> Result<Vec<Subject>, ledger_store::StoreError> {
PackageBooks::links_of(self.0, id)
}
fn attachment_bytes(&self, id: u64) -> Result<Vec<u8>, ledger_store::StoreError> {
PackageBooks::attachment_bytes(self.0, id)
}
}
/// [`books`] with equipment accounts and a bank fee dated `as_of` with its
/// receipt; with `desk`, a desk bought in January and registered, its
/// depreciation posted through as_of's month. With `later`, the days after
/// as well: a payment to the 1099 vendor marked "AFTER AS_OF" with its
/// receipt, a laptop bought and registered as a fixed asset, the bank fee
/// reversed, the members contributing again, and the desk (if any) sold.
fn books_around(dir: &Path, as_of: NaiveDate, later: bool, desk: bool) -> Books {
let mut b = books(dir);
let s = &mut b.store;
for (code, name, ty) in [
("1500", "Equipment", AccountType::Asset),
("1510", "Accumulated depreciation", AccountType::Asset),
("1600", "Furniture", AccountType::Asset),
(
"1610",
"Accumulated depreciation, furniture",
AccountType::Asset,
),
("6500", "Depreciation", AccountType::Expense),
("7900", "Gain or loss on disposal", AccountType::Revenue),
] {
s.add_account(code, name, ty, None, false).unwrap();
}
let fee = post(
s,
&as_of.to_string(),
"Bank fee on the day",
&[
("6000", Side::Debit, "35.00"),
("1000", Side::Credit, "35.00"),
],
);
s.attach_receipt(Some(fee), "fee.pdf", "application/pdf", b"%PDF fee", None)
.unwrap();
let desk = desk.then(|| {
post(
s,
"2026-01-10",
"Desk",
&[
("1600", Side::Debit, "1200.00"),
("1000", Side::Credit, "1200.00"),
],
);
let (cost, accum, expense) = (id_of(s, "1600"), id_of(s, "1610"), id_of(s, "6500"));
let id = s
.register_asset(ledger_core::NewAsset {
description: "Desk".into(),
purchase_date: d("2026-01-10"),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 60,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
for m in 1..=chrono::Datelike::month(&as_of) {
s.run_depreciation(id, &format!("2026-{m:02}")).unwrap();
}
id
});
if later {
let next = as_of.succ_opt().unwrap();
let e = s.create_entry(next, "AFTER AS_OF").unwrap();
s.add_line(e, id_of(s, "6100"), Side::Debit, usd("777.77"))
.unwrap();
s.add_line(e, id_of(s, "1000"), Side::Credit, usd("777.77"))
.unwrap();
s.set_entry_vendor(e, Some(b.vendor)).unwrap();
s.post_entry(e).unwrap();
s.attach_receipt(Some(e), "after.pdf", "application/pdf", b"%PDF after", None)
.unwrap();
let bought = next.succ_opt().unwrap();
post(
s,
&bought.to_string(),
"Laptop",
&[
("1500", Side::Debit, "1800.00"),
("1000", Side::Credit, "1800.00"),
],
);
let (cost, accum, expense) = (id_of(s, "1500"), id_of(s, "1510"), id_of(s, "6500"));
s.register_asset(ledger_core::NewAsset {
description: "Laptop".into(),
purchase_date: bought,
cost: usd("1800.00"),
salvage_value: usd("0.00"),
useful_life_months: 36,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap();
s.reverse_entry(fee, next, "Bank fee refunded").unwrap();
post(
s,
&next.to_string(),
"Members' contributions, again",
&[
("1000", Side::Debit, "600.00"),
("3000", Side::Credit, "300.00"),
("3001", Side::Credit, "300.00"),
],
);
if let Some(desk) = desk {
let (cash, gain) = (id_of(s, "1000"), id_of(s, "7900"));
// Sold at a gain (its book value is at most 1,080.00), credited
// to 7900.
s.dispose_asset(desk, bought, usd("1150.00"), cash, gain)
.unwrap();
}
}
b
}
/// Plan P3, defect 21: "a test pins every file in an as-of package to
/// it". B-33: no figure, entry, receipt or worksheet row reflects anything
/// dated after as_of. Two sets of books, the same through as_of, one with
/// activity in the days after (a 1099 payment with its receipt, an asset
/// bought, a reversal of an entry dated as_of, the members' contributions,
/// a desk bought before as_of sold), make the same package byte for byte,
/// every file and which files there are: statements (PDF and XLSX),
/// ledgers, the fixed asset register, the IIF, the 1099 and K-1
/// worksheets, the receipts and the README; with and without TINs and
/// receipts; with and without the desk (so with and without a register);
/// mid-year and at year end. And the later activity is no phantom: a
/// package a few days later shows it.
#[test]
fn every_file_in_an_as_of_package_stops_at_as_of() {
let company = company(true);
let cash = cash();
for (as_of, desk) in [
("2026-06-30", false),
("2026-06-30", true),
("2026-12-31", false),
("2026-12-31", true),
] {
let as_of = d(as_of);
let dir = tempfile::tempdir().unwrap();
let without = books_around(&dir.path().join("without"), as_of, false, desk);
let with = books_around(&dir.path().join("with"), as_of, true, desk);
let (src_without, src_with) = (tin_source(&without), tin_source(&with));
for receipts in [true, false] {
for tins in [false, true] {
let build =
|b: &Books, src: &dyn Fn(PartyId) -> Result<ledger_vault::Tin, String>| {
let req = PackageRequest {
as_of,
..request(receipts, tins.then_some(src))
};
build_package(&AtSeq(&b.store, 99), &settings(&company, &cash), &req)
.unwrap()
};
let a = build(&without, &src_without);
let b = build(&with, &src_with);
assert_eq!(names(&a), names(&b), "as of {as_of}");
assert_eq!(
names(&a).contains(&"fixed-asset-register.pdf"),
desk,
"as of {as_of}"
);
for ((name, x), (_, y)) in stable(&a.files).iter().zip(stable(&b.files)) {
assert!(*x == y, "{name}, as of {as_of}, shows what came after");
}
assert_eq!(a.tin_gaps, b.tin_gaps);
assert_eq!(a.receipt_count, b.receipt_count);
assert_eq!(a.warnings, b.warnings);
assert_eq!(a.iif_changes, b.iif_changes);
}
}
// A package days on sees the later activity.
let later = PackageRequest {
as_of: as_of + chrono::Duration::days(3),
..request(true, None)
};
let p = build_package(&with.store, &settings(&company, &cash), &later).unwrap();
let iif = text(&p, "general-journal.iif");
for memo in [
"AFTER AS_OF",
"Bank fee refunded",
"Members' contributions, again",
] {
assert!(iif.contains(memo), "{memo}");
}
assert!(names(&p).contains(&"fixed-asset-register.pdf"));
assert!(names(&p).iter().any(|n| n.ends_with("-after.pdf")));
}
}
/// Plan D-36 (P3, defect 21): the 1099 worksheet stops at the package's
/// as_of, like every other file in it: the payments from January 1
/// through as_of (§6041(a) and §6041A(a)(2) count what is paid in the
/// calendar year, so a package as of June 30 shows the year to date, and a
/// vendor not yet at the threshold is not yet reportable). A payment dated
/// as_of counts; one the day after doesn't. A year-end package is
/// unchanged, its README too.
#[test]
fn the_1099_worksheet_stops_at_as_of() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account("6100", "Contract labor", AccountType::Expense, None, false)
.unwrap();
let vendor = s.add_vendor("Contractor Example").unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), false)
.unwrap();
for (date, amount) in [("2026-06-30", "1500.00"), ("2026-07-01", "1000.00")] {
let e = s.create_entry(d(date), "Contract work").unwrap();
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount))
.unwrap();
s.add_line(e, id_of(&s, "1000"), Side::Credit, usd(amount))
.unwrap();
s.set_entry_vendor(e, Some(vendor)).unwrap();
s.post_entry(e).unwrap();
}
let company = company(false);
let cash = cash();
let at = |as_of: &str| {
let req = PackageRequest {
as_of: d(as_of),
..request(false, None)
};
build_package(&s, &settings(&company, &cash), &req).unwrap()
};
let mid = at("2026-06-30");
assert_eq!(
text(&mid, "1099-worksheet-2026.csv"),
"vendor,form,w9_on_file,paid,reportable\n\"Contractor Example\",1099-NEC,false,1500.00,false\n"
);
assert!(text(&mid, "README.txt").contains(
"\n1099-worksheet-2026.csv: cash paid per vendor marked for a 1099 from January 1 through 2026-06-30, against the year's threshold.\n"
));
let year_end = at("2026-12-31");
assert_eq!(
text(&year_end, "1099-worksheet-2026.csv"),
"vendor,form,w9_on_file,paid,reportable\n\"Contractor Example\",1099-NEC,false,2500.00,true\n"
);
assert!(text(&year_end, "README.txt").contains(
"\n1099-worksheet-2026.csv: cash paid per vendor marked for a 1099, against the year's threshold.\n"
));
// The worksheet the tools make for the whole year is unchanged.
let w =
vendor_1099_worksheet(s.ledger(), &settings(&company, &cash), 2026, None, None).unwrap();
assert_eq!(w.lines[0].paid, Decimal::from(2500));
}
/// Plan D-36: the IIF leaves the books' period closes out. QuickBooks
/// keeps no closing entries (it works out retained earnings itself), so an
/// imported close would zero its period's profit there. Two months
/// closed: no close is written; the file's income and expenses are the
/// profit and loss for the year to date; its balances are the trial
/// balance without the closes; and the README says so.
#[test]
fn iif_leaves_out_period_closes() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
post(
&mut s,
"2026-01-15",
"January sale",
&[
("1000", Side::Debit, "5000.00"),
("4000", Side::Credit, "5000.00"),
],
);
post(
&mut s,
"2026-02-10",
"February sale",
&[
("1000", Side::Debit, "700.00"),
("4000", Side::Credit, "700.00"),
],
);
let mut closes = Vec::new();
for (name, start, end) in [
("2026-01", "2026-01-01", "2026-01-31"),
("2026-02", "2026-02-01", "2026-02-28"),
] {
let id = s.create_period(name, d(start), d(end)).unwrap();
closes.push(s.close_period(id).unwrap().expect("income to close"));
}
let ledger = s.ledger();
assert_eq!(
ledger.closing_entry_ids().into_iter().collect::<Vec<_>>(),
closes
);
let as_of = d("2026-03-31");
let company = company(false);
let cash = cash();
let req = PackageRequest {
as_of,
..request(false, None)
};
let p = build_package(&s, &settings(&company, &cash), &req).unwrap();
let iif = text(&p, "general-journal.iif");
let written: Vec<u64> = iif_txns(iif).iter().map(|t| t.0).collect();
let want: Vec<u64> = ledger
.posted_entries()
.filter(|e| e.date() <= as_of && !closes.contains(&e.id()))
.map(|e| e.id())
.collect();
assert_eq!(written, want);
assert_eq!(want.len(), 3, "two sales and the rent");
let balances = iif_balances(iif);
assert_eq!(balances, tb_without_closes(ledger, as_of));
// Income and expenses: the year to date's profit and loss.
let pl = ledger_core::reports::profit_and_loss_for_period(ledger, d("2026-01-01"), as_of, None)
.unwrap();
for l in &pl.revenue {
assert_eq!(balances[&l.code], -l.amount, "{}", l.code);
}
for l in &pl.expenses {
assert_eq!(balances[&l.code], l.amount, "{}", l.code);
}
assert_eq!(pl.total_revenue - pl.total_expenses, Decimal::from(4500));
// The package's own trial balance keeps the closes: income zeroed into
// Retained Earnings through February.
let with_closes = ledger_core::reports::trial_balance_as_of(ledger, as_of);
assert!(with_closes.lines.iter().any(|l| l.code == "3999"));
assert!(!balances.contains_key("3999"));
assert!(text(&p, "README.txt").contains(
"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"
));
}
/// Plan P3 review R18: one list of cash accounts per package. The
/// request's (savings with checking) types the IIF's BANK accounts and
/// counts the 1099 payments the same as it reconciles the cash flow;
/// without one, the settings' (checking alone) do all three.
#[test]
fn one_list_of_cash_accounts_per_package() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account("1010", "Savings", AccountType::Asset, None, false)
.unwrap();
s.add_account("6100", "Contract labor", AccountType::Expense, None, false)
.unwrap();
post(
&mut s,
"2026-01-05",
"Transfer to savings",
&[
("1010", Side::Debit, "3000.00"),
("1000", Side::Credit, "3000.00"),
],
);
let vendor = s.add_vendor("Contractor Example").unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), false)
.unwrap();
let e = s.create_entry(d("2026-05-01"), "Contract work").unwrap();
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd("2100.00"))
.unwrap();
s.add_line(e, id_of(&s, "1010"), Side::Credit, usd("2100.00"))
.unwrap();
s.set_entry_vendor(e, Some(vendor)).unwrap();
s.post_entry(e).unwrap();
let company = company(false);
let cash = cash();
let both = vec!["1000".to_string(), "1010".to_string()];
let mut req = request(false, None);
req.cash_accounts = &both;
let given = build_package(&s, &settings(&company, &cash), &req).unwrap();
let plain = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
let savings = |p: &Package| {
text(p, "general-journal.iif")
.split("\r\n")
.find(|l| l.starts_with("ACCNT\tSavings\t"))
.unwrap()
.to_string()
};
assert_eq!(savings(&given), "ACCNT\tSavings\tBANK\t1010");
assert_eq!(savings(&plain), "ACCNT\tSavings\tOCASSET\t1010");
assert!(text(&given, "1099-worksheet-2026.csv").contains(",2100.00,true\n"));
assert!(text(&plain, "1099-worksheet-2026.csv").contains(",0.00,false\n"));
}
/// 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
/// differently, each with the entries it is in (lead's call, 2026-10-20),
/// so the em dash of many memos is one line; every other file keeps the
/// books' text. Books whose names and memos are plain ASCII get no list.
#[test]
fn the_readme_lists_what_the_iif_writes_differently() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account("6200", "Café 北京", AccountType::Expense, None, false)
.unwrap();
let lunch = post(
&mut s,
"2026-04-02",
"Lunch \u{2014} \u{201C}Zoë\u{201D}\nteam",
&[
("6200", Side::Debit, "42.00"),
("1000", Side::Credit, "42.00"),
],
);
let mut months = Vec::new();
for m in 4..=9 {
months.push(post(
&mut s,
&format!("2026-{m:02}-28"),
&format!("Depreciation \u{2014} Laptop \u{2014} 2026-{m:02}"),
&[
("6000", Side::Debit, "50.00"),
("1000", Side::Credit, "50.00"),
],
));
}
let company = company(false);
let cash = cash();
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
let dashes: Vec<String> = std::iter::once(lunch)
.chain(months.iter().copied())
.map(|e| e.to_string())
.collect();
let want = [
"account 6200's name \"Café 北京\" is written \"Cafe Bei Jing\"".to_string(),
format!(
"\"—\" is written \"--\" in the memos of entries {}",
dashes.join(", ")
),
format!("\"“\" is written \"'\" in the memo of entry {lunch}"),
// "ë", "”" and the line break are side by side: one run.
format!("\"ë”\\n\" is written \"e' \" in the memo of entry {lunch}"),
];
assert_eq!(p.warnings, want);
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",
);
for w in &want {
block.push_str(&format!("- {w}\n"));
}
assert!(readme.contains(&block), "{readme}");
let iif = text(&p, "general-journal.iif");
assert!(iif.is_ascii() && iif.contains("\tCafe Bei Jing\t"));
// The general journal workbook keeps the books' own text.
let gj = xlsx_strings(&p, "general-journal.xlsx");
assert!(
gj.contains("Café 北京") && gj.contains("Lunch — “Zoë”"),
"{gj}"
);
// ASCII books: no list.
let dir = tempfile::tempdir().unwrap();
let (s, _) = plain_books(dir.path());
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(p.warnings.is_empty() && p.iif_changes == 0);
assert!(!text(&p, "README.txt").contains("Written differently"));
}
/// B-34 rule 7 (P3 review round 2): the file names no customer or vendor
/// (Intuit states no such need for an IIF journal; QuickBooks Desktop asks
/// for one on a journal entry to accounts receivable or payable, and takes
/// one such account a transaction: sources/intuit-iif.md), so the README
/// names the entries with a line on either, in file order, and says what
/// QuickBooks may ask. An entry on both is among them. Books with neither
/// get no such sentence.
#[test]
fn the_readme_names_the_entries_on_receivables_and_payables() {
let dir = tempfile::tempdir().unwrap();
let (mut s, rent) = plain_books(dir.path());
for (code, name, ty) in [
("1200", "Accounts Receivable", AccountType::Asset),
("2000", "Accounts Payable", AccountType::Liability),
("4000", "Sales", AccountType::Revenue),
] {
s.add_account(code, name, ty, None, false).unwrap();
}
let invoiced = post(
&mut s,
"2026-04-01",
"Invoice 7",
&[
("1200", Side::Debit, "500.00"),
("4000", Side::Credit, "500.00"),
],
);
let billed = post(
&mut s,
"2026-04-02",
"Bill 3",
&[
("6000", Side::Debit, "80.00"),
("2000", Side::Credit, "80.00"),
],
);
let netted = post(
&mut s,
"2026-04-03",
"Netted",
&[
("2000", Side::Debit, "80.00"),
("1200", Side::Credit, "80.00"),
],
);
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("ACCNT\tAccounts Receivable\tAR\t1200\r\n"),
"{iif}"
);
assert!(
iif.contains("ACCNT\tAccounts Payable\tAP\t2000\r\n"),
"{iif}"
);
// No NAME column on the transactions.
assert!(
iif.contains("\r\n!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\r\n")
);
assert_eq!(
iif_txns(iif).iter().map(|t| t.0).collect::<Vec<_>>(),
[rent, invoiced, billed, netted]
);
let readme = text(&p, "README.txt");
assert!(
readme.contains(&format!(
"\nIt names no customer or vendor on its lines to accounts receivable or payable, in entries {invoiced}, {billed}, {netted}: QuickBooks Desktop asks for one on a journal entry to A/R or A/P (and takes only one A/R or A/P account a transaction); add it there if QuickBooks asks.\n"
)),
"{readme}"
);
// One entry: "entry", not "entries".
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
s.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
let one = post(
&mut s,
"2026-04-01",
"Owed",
&[
("1200", Side::Debit, "5.00"),
("1000", Side::Credit, "5.00"),
],
);
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(text(&p, "README.txt").contains(&format!(
"lines to accounts receivable or payable, in entry {one}: "
)));
// Neither: no sentence.
let dir = tempfile::tempdir().unwrap();
let (s, _) = plain_books(dir.path());
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(!text(&p, "README.txt").contains("customer or vendor"));
}
/// An XLSX's shared strings, as text.
fn xlsx_strings(p: &Package, name: &str) -> String {
let (_, bytes) = p.files.iter().find(|(n, _)| n == name).unwrap();
let mut x = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = String::new();
x.by_name("xl/sharedStrings.xml")
.unwrap()
.read_to_string(&mut out)
.unwrap();
out
}
/// Random books for [`iif_ties_to_the_trial_balance_as_of`]: entries of
/// (day of 2026, debit account, credit account, cents), reversals of some
/// of them a few days on, and the first `closed` months closed.
fn random_ledger(
entries: &[(i64, usize, usize, i64)],
reversals: &[(proptest::sample::Index, i64)],
closed: u32,
) -> Ledger {
let mut l = Ledger::new(Currency::USD);
let mut ids = Vec::new();
for (code, name, ty) in [
("1000", "Checking", AccountType::Asset),
("1100", "Receivables", AccountType::Asset),
("2000", "Card", AccountType::Liability),
("3000", "Capital", AccountType::Equity),
("4000", "Sales", AccountType::Revenue),
("6000", "Rent", AccountType::Expense),
] {
ids.push(l.add_account(code, name, ty, None, false).unwrap());
}
let jan1 = d("2026-01-01");
let mut posted = Vec::new();
for (i, &(day, dr, cr, cents)) in entries.iter().enumerate() {
let cr = if cr == dr { (dr + 1) % ids.len() } else { cr };
let e = l.create_entry(jan1 + chrono::Duration::days(day), &format!("Entry {i}"));
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
l.add_line(e, ids[dr], Side::Debit, m).unwrap();
l.add_line(e, ids[cr], Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
posted.push(e);
}
for (pick, days) in reversals {
if posted.is_empty() {
break;
}
let e = posted[pick.index(posted.len())];
let on = l.get_entry(e).unwrap().date() + chrono::Duration::days(*days);
// An entry is reversed at most once (B-10): a second pick is skipped.
let _ = l.reverse_entry(e, on, "Reversal");
}
for m in 1..=closed {
let start = NaiveDate::from_ymd_opt(2026, m, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, m + 1, 1)
.unwrap()
.pred_opt()
.unwrap();
let p = l
.create_period(&format!("2026-{m:02}"), start, end)
.unwrap();
l.close_period(p).unwrap();
}
l
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(2000))]
/// B-33 and B-34 over random books, random as_of (before the first
/// entry, on an entry's date, after the last), reversals on either side
/// of it and closed months: the IIF holds exactly the posted entries
/// dated on or before as_of that aren't closes, in posting order, each
/// numbered with its id and summing to zero; it declares every account;
/// its balances are the trial balance as of as_of without the closes.
#[test]
fn iif_ties_to_the_trial_balance_as_of(
entries in proptest::collection::vec((0i64..120, 0usize..6, 0usize..6, 1i64..10_000_000), 0..20),
reversals in proptest::collection::vec((proptest::prelude::any::<proptest::sample::Index>(), 0i64..40), 0..4),
closed in 0u32..4,
as_of in -5i64..170,
) {
let l = random_ledger(&entries, &reversals, closed);
let as_of = d("2026-01-01") + chrono::Duration::days(as_of);
let iif = ledger_package::export_iif(&l, &cash(), as_of).unwrap();
let closes = l.closing_entry_ids();
let want: Vec<u64> = l
.posted_entries()
.filter(|e| e.date() <= as_of && !closes.contains(&e.id()))
.map(|e| e.id())
.collect();
let txns = iif_txns(&iif.text);
proptest::prop_assert_eq!(txns.iter().map(|t| t.0).collect::<Vec<_>>(), want);
for (id, _, lines) in &txns {
proptest::prop_assert_eq!(lines.iter().map(|(_, a)| *a).sum::<Decimal>(), Decimal::ZERO, "entry {}", id);
}
let declared = iif.text.split("\r\n").filter(|l| l.starts_with("ACCNT\t")).count();
proptest::prop_assert_eq!(declared, l.chart().len());
proptest::prop_assert_eq!(iif_balances(&iif.text), tb_without_closes(&l, as_of));
proptest::prop_assert!(iif.changes.is_empty());
}
}
/// The bookspec example `bookspec/examples/iif-quickbooks-journal-file.toml`
/// (P3's accept: each regression test also a bookspec example), its
/// expected values derived by hand, against the engine: the example's
/// books, built from its setup and events, export the very file and
/// changes it says.
#[test]
fn the_bookspec_iif_example_matches_the_engine() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../bookspec/examples/iif-quickbooks-journal-file.toml"
);
let spec: toml::Value = toml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let setup = &spec["setup"];
let mut l = Ledger::new(Currency::USD);
for a in setup["accounts"].as_array().unwrap() {
let ty = match a["type"].as_str().unwrap() {
"asset" => AccountType::Asset,
"liability" => AccountType::Liability,
"equity" => AccountType::Equity,
"revenue" => AccountType::Revenue,
_ => AccountType::Expense,
};
let parent = a
.get("parent")
.map(|p| l.chart().find_by_code(p.as_str().unwrap()).unwrap().id);
l.add_account(
a["code"].as_str().unwrap(),
a["name"].as_str().unwrap(),
ty,
parent,
false,
)
.unwrap();
}
for (i, e) in spec["events"].as_array().unwrap().iter().enumerate() {
let id = l.create_entry(
d(e["date"].as_str().unwrap()),
e["description"].as_str().unwrap(),
);
assert_eq!(
id,
i as u64 + 1,
"an event's entry id is its index plus one"
);
for line in e["lines"].as_array().unwrap() {
let account = l
.chart()
.find_by_code(line["account"].as_str().unwrap())
.unwrap()
.id;
let side = match line["side"].as_str().unwrap() {
"debit" => Side::Debit,
_ => Side::Credit,
};
l.add_line(id, account, side, usd(line["amount"].as_str().unwrap()))
.unwrap();
}
l.post_entry(id).unwrap();
}
let cash: Vec<String> = setup["cash_accounts"]
.as_array()
.unwrap()
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect();
let strs = |v: &toml::Value| -> Vec<String> {
v.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap().to_string())
.collect()
};
let mut checked = 0;
for x in spec["expect"].as_array().unwrap() {
let iif = ledger_package::export_iif(&l, &cash, d(x["as_of"].as_str().unwrap())).unwrap();
match x["query"].as_str().unwrap() {
"iif_lines" => {
let mut want = strs(&x["values"]).join("\r\n");
want.push_str("\r\n");
assert_eq!(iif.text, want);
}
"iif_changes" => {
let got: Vec<Vec<String>> = iif
.changes
.iter()
.map(|c| {
let what = match &c.field {
ledger_import::IifField::AccountName { code } => format!("name {code}"),
ledger_import::IifField::Memo { entry } => format!("memo {entry}"),
ledger_import::IifField::AccountNumber
| ledger_import::IifField::AccountNumberLeftOut { .. } => {
"number".to_string()
}
ledger_import::IifField::AtTopLevel { code, .. } => {
format!("top level {code}")
}
};
vec![what, c.books.clone(), c.written.clone()]
})
.collect();
let want: Vec<Vec<String>> =
x["values"].as_array().unwrap().iter().map(strs).collect();
assert_eq!(got, want);
}
q => panic!("unknown query {q}"),
}
checked += 1;
}
assert_eq!(checked, 2);
}