//! The CPA package and the 1099 worksheet, built over a small set of books //! through both views of them (`Store`, `Snapshot`): the files, their //! order, their contents, and the TINs, with a TIN source and without. use chrono::NaiveDate; use ledger_core::settings::{Company, Member}; use ledger_core::{ AccountType, Currency, DocumentLine, 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; const MEMBER_SSN: &str = "241-40-4915"; const EIN: &str = "12-3279105"; fn d(s: &str) -> NaiveDate { NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() } fn usd(s: &str) -> Money { Money::new(Decimal::from_str(s).unwrap(), Currency::USD) } struct Books { store: Store, rent: u64, sale: u64, contribution: u64, acme: PartyId, tobin: PartyId, mo: PartyId, vendor: u64, } fn id_of(s: &Store, code: &str) -> ledger_core::AccountId { s.ledger().chart().find_by_code(code).unwrap().id } fn post(s: &mut Store, date: &str, desc: &str, lines: &[(&str, Side, &str)]) -> u64 { let e = s.create_entry(d(date), desc).unwrap(); for (code, side, a) in lines { let id = id_of(s, code); s.add_line(e, id, *side, usd(a)).unwrap(); } s.post_entry(e).unwrap(); e } /// Two members (owner, with TIN facts; Mo, without), a contractor paid /// past 2026's threshold, a receipt filed on the rent and one linked to /// the sale, a company document, a loose receipt, and a withdrawn file. fn books(dir: &Path) -> Books { let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap(); for (code, name, ty) in [ ("1000", "Checking", AccountType::Asset), ("3000", "Capital, Tobin", AccountType::Equity), ("3001", "Capital, Mo", AccountType::Equity), ("4000", "Sales", AccountType::Revenue), ("6000", "Rent", AccountType::Expense), ("6100", "Contract labor", AccountType::Expense), ] { s.add_account(code, name, ty, None, false).unwrap(); } let tobin = s.add_party("Tobin Example", &[Role::Member]).unwrap(); let mo = s.add_party("Mo Example", &[Role::Member]).unwrap(); s.set_party_tin( tobin, Some(TinFacts { tin_type: TinType::Ssn, last4: "4915".into(), vault_record: None, purged: None, }), ) .unwrap(); let vendor = s.add_vendor("Acme, \"Design\"").unwrap(); let acme = s.ledger().party_of_vendor(vendor).unwrap(); s.set_vendor_1099(vendor, &nec(), Some(true)).unwrap(); s.set_party_tin( acme, Some(TinFacts { tin_type: TinType::Ein, last4: "9105".into(), vault_record: None, purged: None, }), ) .unwrap(); let contribution = post( &mut s, "2026-01-02", "Members' contributions", &[ ("1000", Side::Debit, "10000.00"), ("3000", Side::Credit, "5000.00"), ("3001", Side::Credit, "5000.00"), ], ); let sale = post( &mut s, "2026-02-01", "Sale", &[ ("1000", Side::Debit, "4000"), ("4000", Side::Credit, "4000"), ], ); let rent = post( &mut s, "2026-03-01", "Rent, \"March\"", &[ ("6000", Side::Debit, "1200"), ("1000", Side::Credit, "1200"), ], ); let e = s.create_entry(d("2026-04-01"), "Acme design work").unwrap(); s.add_line(e, id_of(&s, "6100"), Side::Debit, usd("2500")) .unwrap(); s.add_line(e, id_of(&s, "1000"), Side::Credit, usd("2500")) .unwrap(); s.set_entry_vendor(e, Some(vendor)).unwrap(); s.post_entry(e).unwrap(); // After as_of: not in the package. post( &mut s, "2027-01-05", "Next year's rent", &[ ("6000", Side::Debit, "1200"), ("1000", Side::Credit, "1200"), ], ); s.attach_receipt( Some(rent), "rent/march.pdf", "application/pdf", b"%PDF rent", None, ) .unwrap(); let (linked, _, _) = s .attach_receipt(None, "invoice.pdf", "application/pdf", b"%PDF sale", None) .unwrap(); s.link_attachment(linked, Subject::Entry(sale)).unwrap(); s.file_document( "legal", "operating agreement.pdf", "application/pdf", b"%PDF agreement", None, ) .unwrap(); s.attach_receipt(None, "loose.pdf", "application/pdf", b"%PDF loose", None) .unwrap(); let (gone, _, _) = s .file_document("tax", "mistake.pdf", "application/pdf", b"%PDF oops", None) .unwrap(); s.withdraw_attachment(gone, "filed by mistake").unwrap(); Books { store: s, rent, sale, contribution, acme, tobin, mo, vendor, } } fn company(members: bool) -> Company { let member = |name: &str, capital: &str| Member { name: name.into(), share: Decimal::from_str("0.5").unwrap(), capital_account: capital.into(), distribution_account: None, files_jointly_with: None, }; Company { name: "Example Labs LLC".into(), basis: "Cash".into(), members: if members { // Spouses: each names the other (settings require both). let mut tobin = member("Tobin Example", "3000"); tobin.files_jointly_with = Some("Mo Example".into()); let mut mo = member("Mo Example", "3001"); mo.files_jointly_with = Some("Tobin Example".into()); vec![tobin, mo] } else { Vec::new() }, ..Company::default() } } /// A 1099-NEC decision from the beginning, decided by a member. fn nec() -> ledger_core::Dated1099 { ledger_core::Dated1099::from_beginning( ledger_core::Decision1099::Files(Form1099::Nec), Some(ledger_core::settings::Source { decided_by: "Tobin Example".into(), decided_on: d("2026-01-02"), citation: None, note: None, }), ) } /// The worksheet CSV's row for Acme in `books`, without TINs. fn acme_row(vendor: u64) -> String { format!( "{vendor},\"Acme, \"\"Design\"\"\",nec,1099-NEC,,,Tobin Example,2026-01-02,,nec,2500.00,0.00,0.00,0.00,reportable,true,true,9105" ) } fn cash() -> Vec { vec!["1000".to_string()] } fn settings<'a>(company: &'a Company, cash: &'a [String]) -> Settings<'a> { Settings { company, cash_accounts: cash, financing_accounts: &[], investing_accounts: &[], } } fn request<'a>( include_receipts: bool, tins: Option>, ) -> PackageRequest<'a> { PackageRequest { as_of: d("2026-12-31"), prepared_on: d("2027-01-15"), cash_accounts: &[], financing_accounts: &[], investing_accounts: &[], include_receipts, tins, } } fn names(p: &Package) -> Vec<&str> { p.files.iter().map(|(n, _)| n.as_str()).collect() } fn text<'p>(p: &'p Package, name: &str) -> &'p str { let (_, bytes) = p .files .iter() .find(|(n, _)| n == name) .unwrap_or_else(|| panic!("{name} in {:?}", names(p))); std::str::from_utf8(bytes).unwrap() } /// `files` with each XLSX as its parts, its creation stamp /// (docProps/core.xml's dcterms:created and dcterms:modified, the clock's /// time when it is written) blanked: the only bytes of a package that /// depend on when it is built. fn stable(files: &[(String, Vec)]) -> Vec<(String, Vec)> { files .iter() .map(|(name, bytes)| { if !name.ends_with(".xlsx") { return (name.clone(), bytes.clone()); } let mut x = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut out = Vec::new(); for i in 0..x.len() { let mut part = x.by_index(i).unwrap(); let part_name = part.name().unwrap().to_string(); let mut b = Vec::new(); part.read_to_end(&mut b).unwrap(); if part_name == "docProps/core.xml" { let mut t = String::from_utf8(b).unwrap(); for tag in ["dcterms:created", "dcterms:modified"] { let open = t.find(&format!("<{tag}")).unwrap(); let start = open + t[open..].find('>').unwrap() + 1; let end = start + t[start..].find(&format!("")).unwrap(); t.replace_range(start..end, "STAMP"); } b = t.into_bytes(); } out.extend_from_slice(part_name.as_bytes()); out.push(0); out.extend_from_slice(&b); out.push(0); } (name.clone(), out) }) .collect() } /// The zip holds exactly the files, in order, byte for byte. fn assert_zip_is_the_files(p: &Package) { let mut z = zip::ZipArchive::new(std::io::Cursor::new(&p.zip)).unwrap(); assert_eq!(z.len(), p.files.len()); for (i, (name, bytes)) in p.files.iter().enumerate() { let mut f = z.by_index(i).unwrap(); assert_eq!(f.name().unwrap(), name.as_str()); let mut got = Vec::new(); f.read_to_end(&mut got).unwrap(); assert_eq!(&got, bytes, "{name}"); } } #[test] fn a_single_member_package_holds_the_documented_files_in_order() { 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(true, None)).unwrap(); assert_eq!(p.file_name, "cpa-package-2026-12-31.zip"); let sale_date = "2026-02-01"; let want = vec![ "README.txt".to_string(), "profit-and-loss.pdf".into(), "profit-and-loss.xlsx".into(), "balance-sheet.pdf".into(), "balance-sheet.xlsx".into(), "cash-flow.pdf".into(), "cash-flow.xlsx".into(), "tax-summary.pdf".into(), "tax-summary.xlsx".into(), "trial-balance.pdf".into(), "trial-balance.xlsx".into(), "general-ledger.xlsx".into(), "general-journal.xlsx".into(), "1099-worksheet-2026.csv".into(), // Attachment order: the rent's receipt (its name cleaned), the // sale's linked one, the company's document; never the loose // receipt or the withdrawn file. format!("receipts/2026-03-01-entry-{}-rent_march.pdf", b.rent), format!("receipts/{sale_date}-entry-{}-invoice.pdf", b.sale), "documents/legal/operating agreement.pdf".into(), "receipts/index.csv".into(), "general-journal.iif".into(), ]; assert_eq!(names(&p), want); assert_eq!(p.receipt_count, 2); assert!(p.tin_gaps.is_empty()); for (name, bytes) in &p.files { if name.ends_with(".pdf") && !name.contains('/') { assert!(bytes.starts_with(b"%PDF-"), "{name}"); } if name.ends_with(".xlsx") { assert!(bytes.starts_with(b"PK"), "{name}"); } } let rent_sha = ledger_store::journal::sha256_hex(b"%PDF rent"); let sale_sha = ledger_store::journal::sha256_hex(b"%PDF sale"); assert_eq!( text(&p, "receipts/index.csv"), format!( "entry_id,date,description,amount,file,sha256\n\ {rent},2026-03-01,\"Rent, \"\"March\"\"\",1200.00,receipts/2026-03-01-entry-{rent}-rent_march.pdf,{rent_sha}\n\ {sale},2026-02-01,\"Sale\",4000.00,receipts/2026-02-01-entry-{sale}-invoice.pdf,{sale_sha}\n", rent = b.rent, sale = b.sale, ) ); // The package's 1099 worksheet is books.vendor_1099's CSV, byte for // byte (one builder: P3 brief 5). let w = vendor_1099_worksheet( b.store.ledger(), &settings(&company, &cash), 2026, None, None, None, ) .unwrap(); assert_eq!( text(&p, "1099-worksheet-2026.csv"), vendor_1099_csv(&w, false) ); assert_eq!( text(&p, "1099-worksheet-2026.csv"), format!( "{}\n{}\n", ledger_package::VENDOR_1099_CSV_HEADER, acme_row(b.vendor) ) ); let readme = text(&p, "README.txt"); assert!(readme.starts_with("Example Labs LLC — books package for the CPA\n\nFiscal year to date: 2026-01-01 through 2026-12-31. Cash basis.\n"), "{readme}"); assert!( readme.contains(&format!("({} events, verified)", b.store.journal_seq())), "{readme}" ); assert!(readme.contains("receipts/: the 2 receipts behind the period's entries")); // Defect 12's wording: what the worksheet holds, every vendor paid in // the year (decided for a 1099 or not), not "marked for a 1099". assert!( readme.contains("1099-worksheet-2026.csv: each vendor paid in 2026 or decided for a 1099: what counts toward its 1099 against the year's threshold, what was paid by card or payment network (reported by the processor on Form 1099-K, Treas. Reg. §1.6041-1(a)(1)(iv)), what can't be classified until its payment method is recorded, and its 1099 decision with the reason, who made it, and when it took effect.\n"), "{readme}" ); assert!(readme.contains("cash flow, tax summary, trial balance.")); assert!(!readme.contains("k1-members.csv") && !readme.contains("full taxpayer")); 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 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); } #[test] fn without_receipts_the_package_holds_no_evidence() { let dir = tempfile::tempdir().unwrap(); let b = books(dir.path()); let company = company(false); let cash = cash(); let with = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap(); let p = build_package(&b.store, &settings(&company, &cash), &request(false, None)).unwrap(); assert!( !names(&p) .iter() .any(|n| n.starts_with("receipts/") || n.starts_with("documents/")), "{:?}", names(&p) ); assert_eq!(p.receipt_count, 0); // Everything else is the same, byte for byte, but the README's // evidence lines. let rest = |p: &Package| -> Vec<(String, Vec)> { p.files .iter() .filter(|(n, _)| { n != "README.txt" && !n.starts_with("receipts/") && !n.starts_with("documents/") }) .cloned() .collect() }; assert_eq!(stable(&rest(&p)), stable(&rest(&with))); let readme = text(&p, "README.txt"); assert!(!readme.contains("receipts/") && !readme.contains("documents/")); assert_eq!( text(&with, "README.txt").replace( "receipts/: the 2 receipts behind the period's entries; index.csv ties each to its entry, date, amount, and SHA-256.\ndocuments/: the company's formation, legal, and tax documents.\n", "" ), readme ); assert_zip_is_the_files(&p); } /// Tobin's and Acme's TINs from the source; Mo's isn't there. fn tin_source(b: &Books) -> impl Fn(PartyId) -> Result + '_ { move |id| { if id == b.tobin { Ok(ledger_vault::Tin::new(MEMBER_SSN, ledger_vault::TinType::Ssn).unwrap()) } else if id == b.acme { Ok(ledger_vault::Tin::new(EIN, ledger_vault::TinType::Ein).unwrap()) } else { Err("TIN not in the vault: record it with books.set_tin".into()) } } } #[test] fn members_get_the_k1_worksheet_with_full_tins_only_from_a_source() { let dir = tempfile::tempdir().unwrap(); let b = books(dir.path()); let company = company(true); let cash = cash(); let source = tin_source(&b); let with = build_package( &b.store, &settings(&company, &cash), &request(true, Some(&source)), ) .unwrap(); let without = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap(); for p in [&with, &without] { let n = names(p); assert!(n.contains(&"members-equity.pdf") && n.contains(&"members-equity.xlsx")); // The K-1 worksheet (book, then tax basis) comes after the // evidence, before the IIF. assert_eq!( &n[n.len() - 3..], [ "k1-members.csv", "k1-tax-basis-capital.csv", "general-journal.iif" ] ); assert_zip_is_the_files(p); } // Capital: 5000 in each; income 4000 - 1200 - 2500 = 300, half each. assert_eq!( text(&with, "k1-members.csv"), format!( "member,party_id,share,capital_account,tin,tin_type,tin_note,beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n\ Tobin Example,{s},0.50,3000,{MEMBER_SSN},ssn,,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\ Mo Example,{k},0.50,3001,,,TIN not in the vault: record it with books.set_tin,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\ \"Total, all members (not a K-1)\",,1.00,,,,,0.00,10000.00,0.00,300.00,0.00,0.00,10300.00\n", s = b.tobin, k = b.mo, ) ); assert_eq!( text(&without, "k1-members.csv"), format!( "member,party_id,share,capital_account,tin_last4,tin_type,beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n\ Tobin Example,{s},0.50,3000,4915,ssn,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\ Mo Example,{k},0.50,3001,,,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\ \"Total, all members (not a K-1)\",,1.00,,,,0.00,10000.00,0.00,300.00,0.00,0.00,10300.00\n", s = b.tobin, k = b.mo, ) ); assert_eq!( text(&with, "1099-worksheet-2026.csv"), format!( "{},tin,tin_type,tin_note\n{},{EIN},ein,\n", ledger_package::VENDOR_1099_CSV_HEADER, acme_row(b.vendor) ) ); assert!(!text(&without, "1099-worksheet-2026.csv").contains(EIN)); // The gap: Mo's, in tin_gaps and the README. assert_eq!( with.tin_gaps, ["member Mo Example: TIN not in the vault: record it with books.set_tin"] ); assert!(without.tin_gaps.is_empty()); let readme = text(&with, "README.txt"); assert!(readme.contains("cash flow, members' equity, tax summary")); assert!(readme.contains(", with each payee's TIN.\n")); assert!(readme.contains("with their taxpayer ID; the last row totals")); // Said once for the couple. assert_eq!( readme .matches("file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).\n") .count(), 1 ); assert!(readme.contains("\nTobin Example and Mo Example file a joint individual return")); assert!(readme.contains( "\nThis package holds full taxpayer identification numbers: keep it out of email and shared folders, and delete copies once filed.\nTINs not included:\n- member Mo Example: TIN not in the vault: record it with books.set_tin\n" )); let plain = text(&without, "README.txt"); assert!(plain.contains("with their taxpayer ID's last four;")); assert!(!plain.contains("full taxpayer") && !plain.contains("payee's TIN")); for (_, bytes) in &without.files { let s = String::from_utf8_lossy(bytes); assert!(!s.contains(MEMBER_SSN) && !s.contains(EIN)); } let _ = b.contribution; } #[test] fn a_store_and_a_snapshot_of_the_same_books_build_the_same_package() { let dir = tempfile::tempdir().unwrap(); let b = books(dir.path()); let snap = Snapshot::load(&dir.path().join("books-data")).unwrap(); assert_eq!( PackageBooks::journal_seq(&snap), PackageBooks::journal_seq(&b.store) ); for a in b.store.list_attachments(None).unwrap() { let mut from_store: Vec = b.store.links_of(a.id).unwrap(); from_store.retain(|s| a.entry_id.map(Subject::Entry) != Some(*s)); assert_eq!( snap.links_of(a.id).unwrap(), from_store, "attachment {}", a.id ); } let company = company(true); let cash = cash(); let source = tin_source(&b); for tins in [None, Some(&source as ledger_package::TinSource<'_>)] { for receipts in [true, false] { let s = settings(&company, &cash); let from_store = build_package(&b.store, &s, &request(receipts, tins)).unwrap(); let from_snap = build_package(&snap, &s, &request(receipts, tins)).unwrap(); assert_eq!(stable(&from_store.files), stable(&from_snap.files)); assert_eq!(from_store.tin_gaps, from_snap.tin_gaps); assert_eq!(from_store.receipt_count, from_snap.receipt_count); } } } #[test] fn a_package_needs_a_cash_account() { let dir = tempfile::tempdir().unwrap(); let b = books(dir.path()); let company = company(false); let err = build_package(&b.store, &settings(&company, &[]), &request(true, None)).unwrap_err(); assert_eq!( err, "cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)" ); // A request's own accounts override the settings'. let mut req = request(true, None); let bad = vec!["9999".to_string()]; req.cash_accounts = &bad; let err = build_package(&b.store, &settings(&company, &cash()), &req).unwrap_err(); assert_eq!(err, "unknown cash account code: 9999"); } #[test] fn the_1099_worksheet_carries_tins_only_from_a_source() { let dir = tempfile::tempdir().unwrap(); let b = books(dir.path()); let company = company(false); let cash = cash(); let s = settings(&company, &cash); let ledger = b.store.ledger(); let plain = vendor_1099_worksheet(ledger, &s, 2026, None, None, None).unwrap(); assert_eq!(plain.year, 2026); assert_eq!(plain.threshold, Decimal::from(2000)); assert!(!plain.tins_included); assert_eq!(plain.lines.len(), 1); let l = &plain.lines[0]; assert_eq!(l.vendor_id, b.vendor); assert_eq!(l.party_id, Some(b.acme)); assert_eq!(l.paid, Decimal::from(2500)); assert!(l.reportable && l.tin_on_file); assert_eq!(l.tin_last4.as_deref(), Some("9105")); assert_eq!((&l.tin, &l.tin_type, &l.tin_note), (&None, &None, &None)); assert!(plain.missing_w9.is_empty()); // Of 2026's untagged entries only the rent paid money out for // something: the contributions and the sale brought money in (P3 // review R7, the quieter note). assert_eq!(plain.untagged_payments, vec![b.rent]); assert_eq!( plain.notes, [ "Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(), "Each vendor's 1099 decision says why when none is due (a corporation, the United States or a state: Treas. Reg. §1.6041-3); payments to a corporation for legal or medical services stay reportable, and royalties report at $10. Confirm with the CPA.".to_string(), format!("1 posted entry in 2026 paid money out with no vendor tag (entry {}); tag any that paid a contractor or landlord with books.set_entry_vendor.", b.rent), ] ); assert_eq!( vendor_1099_csv(&plain, false), format!( "{}\n{}\n", ledger_package::VENDOR_1099_CSV_HEADER, acme_row(b.vendor) ) ); let source = tin_source(&b); let full = vendor_1099_worksheet(ledger, &s, 2026, None, None, Some(&source)).unwrap(); assert!(full.tins_included); assert_eq!(full.lines[0].tin.as_deref(), Some(EIN)); assert_eq!(full.lines[0].tin_type.as_deref(), Some("ein")); assert_eq!( vendor_1099_csv(&full, true), format!( "{},tin,tin_type,tin_note\n{},{EIN},ein,\n", ledger_package::VENDOR_1099_CSV_HEADER, acme_row(b.vendor) ) ); // A source without the payee's TIN: the note says why. let none = |_: PartyId| -> Result { Err("the TIN vault is locked, \"for now\"".into()) }; let locked = vendor_1099_worksheet(ledger, &s, 2026, None, None, Some(&none)).unwrap(); assert_eq!( vendor_1099_csv(&locked, true).lines().nth(1).unwrap(), format!( "{},,,\"the TIN vault is locked, \"\"for now\"\"\"", acme_row(b.vendor) ) ); assert_eq!(vendor_1099_csv_name(2026, false), "1099-worksheet-2026.csv"); assert_eq!( vendor_1099_csv_name(2026, true), "1099-worksheet-2026-with-tins.csv" ); // A year whose threshold isn't known is refused, never guessed; given // one, it is used. let far = vendor_1099_worksheet(ledger, &s, 2031, None, None, None); assert!(far.is_err(), "{far:?}"); let given = vendor_1099_worksheet(ledger, &s, 2031, Some(Decimal::from(2100)), None, None).unwrap(); assert_eq!(given.threshold, Decimal::from(2100)); assert!( given .notes .iter() .any(|n| n.contains("indexed for inflation after 2026; confirm the 2031 amount")) ); } /// Small books with no members: a checking account and rent, one rent /// payment, and whatever the test files. fn plain_books(dir: &Path) -> (Store, u64) { let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap(); for (code, name, ty) in [ ("1000", "Checking", AccountType::Asset), ("3000", "Capital", AccountType::Equity), ("6000", "Rent", AccountType::Expense), ] { s.add_account(code, name, ty, None, false).unwrap(); } let rent = post( &mut s, "2026-03-01", "March rent", &[ ("6000", Side::Debit, "1200.00"), ("1000", Side::Credit, "1200.00"), ], ); (s, rent) } #[test] fn files_of_the_same_name_each_get_their_own_place() { let dir = tempfile::tempdir().unwrap(); let (mut s, rent) = plain_books(dir.path()); // Two receipts named alike on one entry, the second in other case; two // documents named alike in one category. s.attach_receipt( Some(rent), "r.pdf", "application/pdf", b"%PDF-1.4 one", None, ) .unwrap(); s.attach_receipt( Some(rent), "R.pdf", "application/pdf", b"%PDF-1.4 two", None, ) .unwrap(); s.file_document( "legal", "deed.pdf", "application/pdf", b"%PDF-1.4 deed one", None, ) .unwrap(); s.file_document( "legal", "deed.pdf", "application/pdf", b"%PDF-1.4 deed two", None, ) .unwrap(); let company = company(false); let cash = cash(); let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); let receipt = |suffix: &str| format!("receipts/2026-03-01-entry-{rent}-{suffix}"); let all = names(&p); for name in [ receipt("r.pdf"), receipt("R (2).pdf"), "documents/legal/deed.pdf".to_string(), "documents/legal/deed (2).pdf".to_string(), ] { assert!(all.contains(&name.as_str()), "{name} in {all:?}"); } assert_eq!(text(&p, &receipt("r.pdf")), "%PDF-1.4 one"); assert_eq!(text(&p, &receipt("R (2).pdf")), "%PDF-1.4 two"); assert_eq!( text(&p, "documents/legal/deed (2).pdf"), "%PDF-1.4 deed two" ); // The index names each receipt as it is stored. let index = text(&p, "receipts/index.csv"); assert!( index.contains(&receipt("r.pdf")) && index.contains(&receipt("R (2).pdf")), "{index}" ); assert_eq!(p.receipt_count, 2); assert_zip_is_the_files(&p); // And the README says the documents are there. assert!(text(&p, "README.txt").contains("documents/:")); } #[test] fn the_readme_names_only_what_the_package_holds() { let dir = tempfile::tempdir().unwrap(); let (mut s, rent) = plain_books(dir.path()); s.attach_receipt( Some(rent), "r.pdf", "application/pdf", b"%PDF-1.4 one", None, ) .unwrap(); let company = company(false); let cash = cash(); let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); let readme = text(&p, "README.txt"); assert!(!readme.contains("documents/"), "no documents: {readme}"); assert!(readme.contains("1099-worksheet-2026.csv:"), "{readme}"); assert!(names(&p).contains(&"1099-worksheet-2026.csv")); // A year whose 1099 threshold isn't known: the worksheet is left out, // and the README says what is there instead. let mut late = request(true, None); late.as_of = d("2031-06-30"); let p = build_package(&s, &settings(&company, &cash), &late).unwrap(); let readme = text(&p, "README.txt"); assert!( names(&p).contains(&"1099-worksheet-2031-NOT-INCLUDED.txt"), "{:?}", names(&p) ); assert!( readme.contains("1099-worksheet-2031-NOT-INCLUDED.txt:"), "{readme}" ); assert!(!readme.contains("1099-worksheet-2031.csv"), "{readme}"); } #[test] fn a_single_members_readme_promises_no_totals_row() { let dir = tempfile::tempdir().unwrap(); let (s, _) = plain_books(dir.path()); let mut solo = company(false); solo.members = vec![Member { name: "Solo Owner".into(), share: Decimal::ONE, capital_account: "3000".into(), distribution_account: None, files_jointly_with: None, }]; let cash = cash(); let p = build_package(&s, &settings(&solo, &cash), &request(false, None)).unwrap(); let readme = text(&p, "README.txt"); assert!(readme.contains("k1-members.csv:"), "{readme}"); assert!(!readme.contains("the last row totals"), "{readme}"); let k1 = text(&p, "k1-members.csv"); assert_eq!(k1.lines().count(), 2, "a header and the one member: {k1}"); // Two members: the totals row, and the README says so. let two = company(true); let b = books(dir.path().join("two").as_path()); let p = build_package(&b.store, &settings(&two, &cash), &request(false, None)).unwrap(); assert!(text(&p, "README.txt").contains("the last row totals")); } #[test] fn a_dotfiles_twin_keeps_its_whole_name_and_its_folder() { let dir = tempfile::tempdir().unwrap(); let (mut s, _) = plain_books(dir.path()); // A name that is all extension (".env"): its twin is ".env (2)", never // " (2).env", a name made of nothing but a number. for bytes in [b"one".as_slice(), b"two".as_slice()] { s.file_document("legal", ".env", "text/plain", bytes, None) .unwrap(); } let company = company(false); let cash = cash(); let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); let docs: Vec<&str> = names(&p) .into_iter() .filter(|n| n.starts_with("documents/")) .collect(); assert_eq!(docs, ["documents/legal/.env", "documents/legal/.env (2)"]); assert_eq!(text(&p, "documents/legal/.env"), "one"); assert_eq!(text(&p, "documents/legal/.env (2)"), "two"); assert_zip_is_the_files(&p); } #[test] fn a_twins_number_goes_on_its_own_name_and_no_name_climbs_out_of_its_folder() { let dir = tempfile::tempdir().unwrap(); let (mut s, _) = plain_books(dir.path()); // A dot in the folder is not the file's extension: the second "notes" // in "a.b" is "a.b/notes (2)", never "a (2).b/notes" in another // folder. And a name of dots alone would climb out of its folder when // the zip is unpacked: it becomes underscores. for bytes in [b"one".as_slice(), b"two".as_slice()] { s.file_document("a.b", "notes", "text/plain", bytes, None) .unwrap(); } s.file_document("..", "..", "text/plain", b"three", None) .unwrap(); s.file_document("legal", ".", "text/plain", b"four", None) .unwrap(); let company = company(false); let cash = cash(); let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); let docs: Vec<&str> = names(&p) .into_iter() .filter(|n| n.starts_with("documents/")) .collect(); assert_eq!( docs, [ "documents/a.b/notes", "documents/a.b/notes (2)", "documents/__/__", "documents/legal/_" ] ); assert_eq!(text(&p, "documents/a.b/notes (2)"), "two"); assert_eq!(text(&p, "documents/__/__"), "three"); assert!( names(&p).iter().all(|n| n .split('/') .all(|seg| seg != ".." && seg != "." && !seg.is_empty())), "{:?}", names(&p) ); assert_zip_is_the_files(&p); } #[test] fn the_years_first_and_last_days_receipts_are_in_and_the_days_beyond_are_not() { let dir = tempfile::tempdir().unwrap(); let (mut s, _) = plain_books(dir.path()); // The fiscal year is 2026 (the default start, January 1) and as_of is // its last day: the receipts on both edges go in, the ones a day // outside stay out. let mut on = Vec::new(); for date in ["2025-12-31", "2026-01-01", "2026-12-31", "2027-01-01"] { let e = post( &mut s, date, "Rent", &[ ("6000", Side::Debit, "100.00"), ("1000", Side::Credit, "100.00"), ], ); s.attach_receipt(Some(e), "r.pdf", "application/pdf", date.as_bytes(), None) .unwrap(); on.push((date, e)); } let company = company(false); let cash = cash(); let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); let receipts: Vec<&str> = names(&p) .into_iter() .filter(|n| n.starts_with("receipts/") && *n != "receipts/index.csv") .collect(); let first = format!("receipts/2026-01-01-entry-{}-r.pdf", on[1].1); let last = format!("receipts/2026-12-31-entry-{}-r.pdf", on[2].1); assert_eq!(receipts, [first.as_str(), last.as_str()]); assert_eq!(p.receipt_count, 2); assert_eq!(text(&p, &first), "2026-01-01"); assert_eq!(text(&p, &last), "2026-12-31"); assert_eq!(text(&p, "receipts/index.csv").lines().count(), 3); } /// Contract labor paid three ways: from checking (cash), from a prepaid /// asset that isn't cash, and by a member's advance (financing). The /// worksheet counts payments out of the accounts it is given; the CPA /// package gives it its own (the request's, else the settings': one list /// of each per package, P3 review R18), so the package's 1099 worksheet /// is books.vendor_1099's for those accounts, byte for byte. #[test] fn payments_to_a_vendor_come_from_the_packages_accounts() { let dir = tempfile::tempdir().unwrap(); let (mut s, _) = plain_books(dir.path()); for (code, name, ty) in [ ("1300", "Prepaid expenses", AccountType::Asset), ("2100", "Member advances", AccountType::Liability), ("6100", "Contract labor", AccountType::Expense), ] { s.add_account(code, name, ty, None, false).unwrap(); } let vendor = s.add_vendor("Acme Design").unwrap(); s.set_vendor_1099(vendor, &nec(), Some(true)).unwrap(); for (from, amount) in [("1000", "1000.00"), ("1300", "400.00"), ("2100", "700.00")] { let e = s.create_entry(d("2026-05-01"), "Acme design work").unwrap(); s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount)) .unwrap(); s.add_line(e, id_of(&s, from), Side::Credit, usd(amount)) .unwrap(); s.set_entry_vendor(e, Some(vendor)).unwrap(); s.post_entry(e).unwrap(); } let company = company(false); // A code the chart lacks is skipped. let cash = vec!["1000".to_string(), "1999".to_string()]; let financing = vec!["2100".to_string()]; let st = Settings { company: &company, cash_accounts: &cash, financing_accounts: &financing, investing_accounts: &[], }; let ledger = s.ledger(); assert_eq!( ledger_package::payment_accounts(ledger, &st), ledger_core::PaymentAccounts { cash: vec![id_of(&s, "1000")], financing: vec![id_of(&s, "2100")], } ); // Checking counts; the member's advance is undetermined until how // they paid is recorded (their card wouldn't count, their check // would); the prepaid asset is no payment source. let w = vendor_1099_worksheet(ledger, &st, 2026, None, None, None).unwrap(); assert_eq!(w.lines.len(), 1); assert_eq!(w.lines[0].vendor_id, vendor); assert_eq!(w.lines[0].paid, Decimal::from(1000)); assert_eq!(w.lines[0].undetermined, Decimal::from(700)); assert_eq!(w.lines[0].status, ledger_core::Status1099::UnderThreshold); assert_eq!(w.undetermined_payments.len(), 1); // A package naming no accounts carries this worksheet, byte for byte // (its settings name checking alone: the cash flow refuses a code the // chart lacks). let checking = vec!["1000".to_string()]; let st = Settings { cash_accounts: &checking, ..st }; let plain = build_package(&s, &st, &request(false, None)).unwrap(); assert_eq!( text(&plain, "1099-worksheet-2026.csv"), vendor_1099_csv(&w, false) ); // One naming the prepaid asset as cash carries the worksheet for its // accounts (the 400.00 out of the prepaid asset counts, and the // settings' financing stays), the one books.vendor_1099 gives for // them through the package's as_of; the cash flow, the K-1 and the // IIF read the same list. let wider = vec!["1000".to_string(), "1300".to_string()]; let req = PackageRequest { cash_accounts: &wider, ..request(false, None) }; let p = build_package(&s, &st, &req).unwrap(); let theirs = st.with_accounts(&wider, &[]); assert_eq!(theirs.financing_accounts, financing.as_slice()); let w = vendor_1099_worksheet(ledger, &theirs, 2026, None, Some(req.as_of), None).unwrap(); assert_eq!(w.lines[0].paid, Decimal::from(1400)); assert_eq!(w.lines[0].undetermined, Decimal::from(700)); assert_eq!( text(&p, "1099-worksheet-2026.csv"), vendor_1099_csv(&w, false) ); } #[test] fn a_receipts_index_amount_is_the_entrys_debit_total_as_written() { let dir = tempfile::tempdir().unwrap(); let (mut s, _) = plain_books(dir.path()); // A tool takes "100.000" as written (it is whole cents): the two sides // are equal in value but not in spelling. The index prints the debit // side's total, padded to cents, never the credit side's "100.000". let e = post( &mut s, "2026-06-01", "Rent, June", &[ ("6000", Side::Debit, "60"), ("6000", Side::Debit, "40.0"), ("1000", Side::Credit, "100.000"), ], ); s.attach_receipt(Some(e), "june.pdf", "application/pdf", b"%PDF june", None) .unwrap(); let company = company(false); let cash = cash(); let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); let index = text(&p, "receipts/index.csv"); let row = index .lines() .find(|l| l.starts_with(&format!("{e},"))) .unwrap(); let fields: Vec<&str> = row.split(',').collect(); assert_eq!( fields[..4], [e.to_string().as_str(), "2026-06-01", "\"Rent", " June\""] ); 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 { let mut out: Vec = 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 { let number: HashMap = iif .split("\r\n") .map(|l| l.split('\t').collect::>()) .filter(|f| f[0] == "ACCNT") .map(|f| (f[1].to_string(), f[3].to_string())) .collect(); let mut out: BTreeMap = 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 { let mut out: BTreeMap = 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 = 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::>(), 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 { 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::::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 journal(&self) -> Result, ledger_store::StoreError> { PackageBooks::journal(self.0) } fn all_attachments(&self) -> Result, ledger_store::StoreError> { PackageBooks::all_attachments(self.0) } fn extractions(&self) -> Result, ledger_store::StoreError> { PackageBooks::extractions(self.0) } fn links_of(&self, id: u64) -> Result, ledger_store::StoreError> { PackageBooks::links_of(self.0, id) } fn attachment_bytes(&self, id: u64) -> Result, ledger_store::StoreError> { PackageBooks::attachment_bytes(self.0, id) } fn statements(&self) -> Result, ledger_store::StoreError> { PackageBooks::statements(self.0) } fn reconciliations( &self, ) -> Result, ledger_store::StoreError> { PackageBooks::reconciliations(self.0) } } /// [`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(), in_service_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(), in_service_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| { 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, &nec(), Some(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() }; // The worksheet the tools make through a day (books.vendor_1099's // `through`), as CSV. let through = |day: Option<&str>| { let w = vendor_1099_worksheet( s.ledger(), &settings(&company, &cash), 2026, None, day.map(d), None, ) .unwrap(); vendor_1099_csv(&w, false) }; let row = |csv: &str| csv.lines().nth(1).unwrap().to_string(); let mid = at("2026-06-30"); let mid_csv = text(&mid, "1099-worksheet-2026.csv"); assert_eq!(mid_csv, through(Some("2026-06-30"))); // paid 1,500.00, under the $2,000 threshold: not yet reportable. assert!( row(mid_csv).ends_with(",1500.00,0.00,0.00,0.00,under_threshold,false,false,"), "{mid_csv}" ); assert!(text(&mid, "README.txt").contains( "\n1099-worksheet-2026.csv: each vendor paid in 2026 from January 1 through 2026-06-30 or decided for a 1099: what counts toward its 1099 against the year's threshold, " )); let year_end = at("2026-12-31"); let year_csv = text(&year_end, "1099-worksheet-2026.csv"); assert_eq!(year_csv, through(None)); assert!( row(year_csv).ends_with(",2500.00,0.00,0.00,0.00,reportable,true,false,"), "{year_csv}" ); assert!(text(&year_end, "README.txt").contains( "\n1099-worksheet-2026.csv: each vendor paid in 2026 or decided for a 1099: what counts toward its 1099 against the year's threshold, " )); // The worksheet the tools make for the whole year is unchanged. let w = vendor_1099_worksheet( s.ledger(), &settings(&company, &cash), 2026, None, 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::>(), 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 = iif_txns(iif).iter().map(|t| t.0).collect(); let want: Vec = 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, &nec(), Some(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,0.00,0.00,0.00,reportable,true,false,\n") ); assert!( text(&plain, "1099-worksheet-2026.csv") .contains(",0.00,0.00,0.00,0.00,under_threshold,false,false,\n") ); // Each is books.vendor_1099's CSV for the same accounts. let tool = |codes: &[String]| { let w = vendor_1099_worksheet( s.ledger(), &settings(&company, codes), 2026, None, Some(d("2026-12-31")), None, ) .unwrap(); vendor_1099_csv(&w, false) }; assert_eq!(text(&given, "1099-worksheet-2026.csv"), tool(&both)); 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, account: None, quantity: None, unit_price: None, }; 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 /// 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 = 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; 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")); } 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(); } // Hand lines on receivables and payables, as books an earlier version // wrote hold them (the control guard, rules.md B-38, refuses them as // new work): posted as replay posts them. let (invoiced, billed, netted) = ledger_core::parties::replaying(|| { 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"), ], ); (invoiced, billed, netted) }); 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::>(), [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 = ledger_core::parties::replaying(|| { 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")); } /// Every text the package's XLSX `name` holds: its XML parts (the shared /// strings and the sheets), concatenated, `&` read as `&`. fn xlsx_strings(p: &Package, name: &str) -> String { let (_, bytes) = p .files .iter() .find(|(n, _)| n == name) .unwrap_or_else(|| panic!("{name} in {:?}", names(p))); let mut x = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut out = String::new(); for i in 0..x.len() { let mut part = x.by_index(i).unwrap(); if part.name().unwrap().ends_with(".xml") { part.read_to_string(&mut out).unwrap(); } } out.replace("&", "&") } /// 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), ("2050", "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::(), 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 = 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::>(), want); for (id, _, lines) in &txns { proptest::prop_assert_eq!(lines.iter().map(|(_, a)| *a).sum::(), 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(); } // 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()), e["description"].as_str().unwrap(), ); assert_eq!( id, i as u64 + 1, "an event's entry id is its index plus one" ); for (n, line) in e["lines"].as_array().unwrap().iter().enumerate() { 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(); 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(); } let cash: Vec = setup["cash_accounts"] .as_array() .unwrap() .iter() .map(|c| c.as_str().unwrap().to_string()) .collect(); let strs = |v: &toml::Value| -> Vec { 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> = 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}") } ledger_import::IifField::ClassName { class } => { format!("class {class}") } }; vec![what, c.books.clone(), c.written.clone()] }) .collect(); let want: Vec> = x["values"].as_array().unwrap().iter().map(strs).collect(); assert_eq!(got, want); } q => panic!("unknown query {q}"), } checked += 1; } assert_eq!(checked, 2); } /// The package's statement of cash flows is the direct method's (rules.md /// B-14, D-35): operating by ASC 230-10-45-25 class, financing by kind /// with each member named, and both schedules: the reconciliation of net /// income to operating cash (45-30) and the noncash investing and /// financing activities (50-3; "None" here: every entry moved cash). #[test] fn the_cash_flow_carries_both_schedules() { let dir = tempfile::tempdir().unwrap(); let b = books(dir.path()); let company = company(true); let cash = cash(); let p = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap(); let text = xlsx_strings(&p, "cash-flow.xlsx"); for m in &company.members { let want = format!("Contributions from members — {}", m.name); assert!(text.contains(&want), "{want:?} not in the cash flow"); } for want in [ "Statement of Cash Flows", "Cash received from customers", "Cash paid to suppliers and employees", "Members' contributions (#", "Reconciliation of net income (loss) to net cash from operating activities", "Noncash investing and financing activities", "None", ] { assert!(text.contains(want), "{want:?} not in the cash flow"); } assert!(!text.contains("Member distributions")); assert!(p.files.iter().any(|(n, _)| n == "cash-flow.pdf")); } /// The K-1 worksheet reports tax-basis capital (rules.md B-36; Form 1065 /// instructions, Item L, the tax-basis method), beside the book figures, /// with the reconciliation. By hand, per member of two at 50/50: cash in /// 1,000.00; a laptop (worth 1,440.00, adjusted basis 1,688.40) and a /// camera (1,300.00; 1,080.00) contributed jointly from personal use, /// 1,370.00 each at book, 1,384.20 each at §723 basis ((1,688.40 + /// 1,080.00) / 2), the built-in gain share (−248.40 + 220.00) / 2 = /// −14.20; revenue 600.00 less a year of depreciation (24-month lives: /// 720.00 + 650.00 = 1,370.00) = −770.00, −385.00 each, its tax figure /// open (tax depreciation is the CPA's); 300.00 out each. Book ending /// 1,685.00; tax ending as far as the books know 1,699.20; book less tax /// −14.20. #[test] fn the_k1_worksheet_reports_tax_basis_capital() { let dir = tempfile::tempdir().unwrap(); let mut s = Store::create(&dir.path().join("books-data"), Currency::USD).unwrap(); for (code, name, ty) in [ ("1000", "Checking", AccountType::Asset), ("1500", "Equipment", AccountType::Asset), ("1510", "Accumulated Depreciation", AccountType::Asset), ("3000", "Capital, Tobin", AccountType::Equity), ("3001", "Capital, Mo", AccountType::Equity), ("4000", "Sales", AccountType::Revenue), ("6200", "Depreciation", AccountType::Expense), ] { s.add_account(code, name, ty, None, false).unwrap(); } s.add_party("Tobin Example", &[Role::Member]).unwrap(); s.add_party("Mo Example", &[Role::Member]).unwrap(); post( &mut s, "2026-01-01", "Cash in", &[ ("1000", Side::Debit, "2000.00"), ("3000", Side::Credit, "1000.00"), ("3001", Side::Credit, "1000.00"), ], ); let devices = post( &mut s, "2026-01-01", "Devices contributed", &[ ("1500", Side::Debit, "1440.00"), ("1500", Side::Debit, "1300.00"), ("3000", Side::Credit, "1370.00"), ("3001", Side::Credit, "1370.00"), ], ); let (equipment, accum, depreciation) = (id_of(&s, "1500"), id_of(&s, "1510"), id_of(&s, "6200")); for (name, fmv, adjusted) in [ ("Laptop", "1440.00", "1688.40"), ("Camera", "1300.00", "1080.00"), ] { let id = s .register_asset(ledger_core::NewAsset { description: name.into(), in_service_date: d("2026-01-01"), cost: usd(fmv), salvage_value: usd("0.00"), useful_life_months: 24, cost_account: equipment, accum_depr_account: accum, expense_account: depreciation, }) .unwrap(); s.set_asset_acquisition(id, devices).unwrap(); let mut basis = ledger_core::AssetBasis::contribution( ledger_core::BasisRule::PersonalUseConversion, Decimal::from_str(adjusted).unwrap(), Decimal::from_str(fmv).unwrap(), ); basis.sec704c_method = Some(ledger_core::Sec704cMethod::Traditional); s.set_asset_basis(id, basis).unwrap(); for m in 1..=12 { s.run_depreciation(id, &format!("2026-{m:02}")).unwrap(); } } post( &mut s, "2026-02-01", "Sale", &[ ("1000", Side::Debit, "600.00"), ("4000", Side::Credit, "600.00"), ], ); post( &mut s, "2026-12-15", "Draws", &[ ("3000", Side::Debit, "300.00"), ("3001", Side::Debit, "300.00"), ("1000", Side::Credit, "600.00"), ], ); let company = company(true); let cash = cash(); let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap(); let csv = text(&p, "k1-tax-basis-capital.csv"); let lines: Vec<&str> = csv.lines().collect(); assert_eq!( lines[0], "member,item,book,tax_basis,book_less_tax,status,note" ); assert_eq!(lines.len(), 1 + 2 * 12, "{csv}"); let income_note = "the member's share of net income (loss) as figured for tax purposes, from the return (-385.00 on the books; every book-tax difference, Schedule M-1, is the CPA's)"; let depreciation_note = "tax depreciation of the fixed assets in service (MACRS, IRC §168), in place of the book depreciation posted (1370.00 in the span, 1370.00 of it on contributed property, whose tax depreciation each property's §704(c) method allocates, Treas. Reg. §1.704-3)"; for member in ["Tobin Example", "Mo Example"] { for want in [ format!("{member},Beginning capital,0.00,0.00,0.00,tax basis,"), format!("{member},Capital contributed: cash,1000.00,1000.00,0.00,tax basis,"), format!( "{member},Capital contributed: property,1370.00,1384.20,-14.20,tax basis,at the property's adjusted basis (IRC §723); book: its fair market value" ), format!( "{member},Current year net income (loss),-385.00,,,open: the CPA's,\"known so far -385.00; needs {income_note}; {depreciation_note}\"" ), format!("{member},Other increase (decrease),0.00,0.00,0.00,tax basis,"), format!( "{member},Withdrawals and distributions,300.00,300.00,0.00,tax basis,\"cash, plus the adjusted tax basis of property distributed\"" ), format!( "{member},Ending capital,1685.00,,,open: the CPA's,\"known so far 1699.20; needs {income_note}; {depreciation_note}\"" ), format!( "{member},\"Reconciliation: beginning capital, book less tax\",,,0.00,reconciliation," ), format!( "{member},Reconciliation: built-in gain (loss) of property contributed,,,-14.20,reconciliation,\"fair market value less §723 basis at contribution, IRC §704(c) (Treas. Reg. §1.704-3(a)(3)(ii))\"" ), format!( "{member},Reconciliation: book ending less tax ending,,,-14.20,reconciliation,\"to the tax ending the books know so far, before the CPA's figures above\"" ), ] { assert!(lines.contains(&want.as_str()), "{want}\nnot in\n{csv}"); } } let readme = text(&p, "README.txt"); assert!( readme.contains("k1-tax-basis-capital.csv: each member's capital account on the tax basis, as Item L reports it"), "{readme}" ); // The book worksheet is the same as without assets' bases. assert!(text(&p, "k1-members.csv").contains(",1685.00\n")); }