diff --git a/Cargo.lock b/Cargo.lock index a8bfd70..1a04694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1564,6 +1564,12 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "deunicode" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" + [[package]] name = "dify" version = "0.8.0" @@ -3925,6 +3931,9 @@ version = "0.1.0" dependencies = [ "chrono", "csv", + "deunicode", + "hayro-interpret 0.8.0", + "kurbo", "ledger-core", "proptest", "qrcode", @@ -3985,8 +3994,10 @@ dependencies = [ "ledger-import", "ledger-store", "ledger-vault", + "proptest", "rust_decimal", "tempfile", + "toml 1.1.8+spec-1.1.0", "zip 9.0.2", ] diff --git a/bookspec/examples/1099-worksheet-through-as-of.toml b/bookspec/examples/1099-worksheet-through-as-of.toml new file mode 100644 index 0000000..cb930ef --- /dev/null +++ b/bookspec/examples/1099-worksheet-through-as-of.toml @@ -0,0 +1,144 @@ +# What a 1099 worksheet as of a day counts toward a payee: the payments +# of that day's calendar year made from January 1 through the day. +# +# wanderbooks: P3 defect 21 and decision D-36 (the CPA package's 1099 +# worksheet counted the whole calendar year in a package as of June 30); +# rules.md B-19, B-33; regression test `the_1099_worksheet_stops_at_as_of` +# (ledger-package tests/package.rs). +# +# Setup beyond the accounts: `cash_accounts` are the accounts payments +# are made from; each `[[setup.payees]]` is a payee marked for a form; +# an event's `payee` tags the payment to them. Expected values: +# vendor_1099_paid gives each marked payee's payments counted from `from` +# through `to` (both in one calendar year). The 2026 threshold is $2,000 +# (B-19), so the payee is not yet reportable as of June 30 (1400.00) and +# is for the year (3050.00). + +id = "1099-worksheet-through-as-of" +title = "A 1099 worksheet as of a day counts the year's payments through that day" +rule = "A payee's 1099 amount as of a day D MUST be the payments to the payee made from January 1 of D's calendar year through D, excluding payments made with a payment card; as of December 31 it is the whole calendar year's." +status = "AUTHORITY" +sources = [ + "26 U.S.C. §6041(a), as amended by Pub. L. 119-21 §70433: payments \"of $2,000 or more in any calendar year\" (docs/spec/sources/irs-1099-thresholds.md)", + "26 U.S.C. §6041A(a)(2): \"the aggregate of such remuneration paid to such person during such calendar year\" (docs/spec/sources/irs-1099-thresholds.md)", + "IRS, Instructions for Forms 1099-MISC and 1099-NEC (Rev. December 2026), Form 1099-K: \"Payments made with a credit card or payment card and certain other types of payments, including third-party network transactions, must be reported on Form 1099-K by the payment settlement entity under section 6050W and are not subject to reporting on Form 1099-MISC or Form 1099-NEC.\" (docs/spec/sources/irs-1099-thresholds.md)", +] +tags = ["1099", "as-of", "calendar-year", "information-return", "period-boundary"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half_away_from_zero" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "2200" +name = "Credit card" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Owner capital" +type = "equity" + +[[setup.accounts]] +code = "6100" +name = "Contract labor" +type = "expense" + +[[setup.payees]] +name = "Contractor A" +form = "1099-NEC" + +# 0: the year before. +[[events]] +date = "2025-12-31" +description = "Contract work, December" +payee = "Contractor A" +lines = [ + { account = "6100", side = "debit", amount = "100.00" }, + { account = "1000", side = "credit", amount = "100.00" }, +] + +# 1 +[[events]] +date = "2026-01-02" +description = "Owner contribution" +lines = [ + { account = "1000", side = "debit", amount = "10000.00" }, + { account = "3000", side = "credit", amount = "10000.00" }, +] + +# 2 +[[events]] +date = "2026-03-15" +description = "Contract work, March" +payee = "Contractor A" +lines = [ + { account = "6100", side = "debit", amount = "900.00" }, + { account = "1000", side = "credit", amount = "900.00" }, +] + +# 3: by card, so reported on Form 1099-K, never counted here. +[[events]] +date = "2026-06-10" +description = "Contract work, June, by card" +payee = "Contractor A" +lines = [ + { account = "6100", side = "debit", amount = "300.00" }, + { account = "2200", side = "credit", amount = "300.00" }, +] + +# 4: made on the day itself, so counted. +[[events]] +date = "2026-06-30" +description = "Contract work, June" +payee = "Contractor A" +lines = [ + { account = "6100", side = "debit", amount = "500.00" }, + { account = "1000", side = "credit", amount = "500.00" }, +] + +# 5: the day after. +[[events]] +date = "2026-07-01" +description = "Contract work, July" +payee = "Contractor A" +lines = [ + { account = "6100", side = "debit", amount = "700.00" }, + { account = "1000", side = "credit", amount = "700.00" }, +] + +# 6 +[[events]] +date = "2026-11-20" +description = "Contract work, November" +payee = "Contractor A" +lines = [ + { account = "6100", side = "debit", amount = "950.00" }, + { account = "1000", side = "credit", amount = "950.00" }, +] + +[[expect]] +query = "vendor_1099_paid" +from = "2026-01-01" +to = "2026-06-30" +values = { "Contractor A" = "1400.00" } + +[[expect]] +query = "vendor_1099_paid" +from = "2026-01-01" +to = "2026-12-31" +values = { "Contractor A" = "3050.00" } + +[[expect]] +query = "vendor_1099_paid" +from = "2025-01-01" +to = "2025-12-31" +values = { "Contractor A" = "100.00" } diff --git a/bookspec/examples/as-of-package-stops-at-as-of.toml b/bookspec/examples/as-of-package-stops-at-as-of.toml new file mode 100644 index 0000000..4a19df7 --- /dev/null +++ b/bookspec/examples/as-of-package-stops-at-as-of.toml @@ -0,0 +1,119 @@ +# A report as of a day shows the books through that day, and nothing +# dated after it, however long after the day it is made. +# +# wanderbooks: P3 defect 21 (the CPA package's QuickBooks file held entries +# dated after the package's as_of); rules.md B-33; regression tests +# `every_file_in_an_as_of_package_stops_at_as_of` and +# `as_of_package_iif_stops_at_as_of` (ledger-package tests/package.rs). +# +# Expected values: trial_balance gives each account's balance, debit +# positive and credit negative, every account listed; profit_and_loss +# gives each revenue and expense account's activity for the period as a +# positive amount, and net_income (revenue less expenses). + +id = "as-of-package-stops-at-as-of" +title = "A report as of a day leaves out what is dated after it" +rule = "A balance or report as of a day D MUST include every posted entry dated on or before D and MUST NOT include any entry dated after D, whenever it is produced; a period report through D covers the entries dated from the period's first day through D." +status = "CONVENTION" +sources = [ + "IRS Publication 538 (Rev. January 2022), Tax year: \"A tax year is an annual accounting period for keeping records and reporting income and expenses.\" (docs/spec/sources/irs-periods-and-methods.md)", + "Pacioli, Summa de arithmetica (1494), ch. 36, the trial balance: the total of the debits equals the total of the credits (Geijsbeek translation, 1914; docs/spec/sources/pacioli-1494.md)", +] +tags = ["as-of", "trial-balance", "profit-and-loss", "reversal", "period-boundary"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half_away_from_zero" + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Owner capital" +type = "equity" + +[[setup.accounts]] +code = "4000" +name = "Sales" +type = "revenue" + +[[setup.accounts]] +code = "6000" +name = "Rent" +type = "expense" + +# 0 +[[events]] +date = "2026-01-02" +description = "Owner contribution" +lines = [ + { account = "1000", side = "debit", amount = "10000.00" }, + { account = "3000", side = "credit", amount = "10000.00" }, +] + +# 1 +[[events]] +date = "2026-02-01" +description = "Sale" +lines = [ + { account = "1000", side = "debit", amount = "4000.00" }, + { account = "4000", side = "credit", amount = "4000.00" }, +] + +# 2: dated the day itself, so in. +[[events]] +date = "2026-06-30" +description = "June rent" +lines = [ + { account = "6000", side = "debit", amount = "1200.00" }, + { account = "1000", side = "credit", amount = "1200.00" }, +] + +# 3: the day after, so out of anything as of 2026-06-30. +[[events]] +date = "2026-07-01" +description = "July rent" +lines = [ + { account = "6000", side = "debit", amount = "1200.00" }, + { account = "1000", side = "credit", amount = "1200.00" }, +] + +# 4: a reversal dated after the day leaves its original in. +[[events]] +date = "2026-07-15" +description = "Reversal of the February sale" +reverses = 1 +lines = [ + { account = "4000", side = "debit", amount = "4000.00" }, + { account = "1000", side = "credit", amount = "4000.00" }, +] + +[[expect]] +query = "trial_balance" +from = "2026-01-01" +to = "2026-06-30" +values = { "1000" = "12800.00", "3000" = "-10000.00", "4000" = "-4000.00", "6000" = "1200.00" } + +[[expect]] +query = "profit_and_loss" +from = "2026-01-01" +to = "2026-06-30" +values = { "4000" = "4000.00", "6000" = "1200.00", net_income = "2800.00" } + +# The same books a month later: the July rent and the reversal are in. +[[expect]] +query = "trial_balance" +from = "2026-01-01" +to = "2026-07-31" +values = { "1000" = "7600.00", "3000" = "-10000.00", "4000" = "0.00", "6000" = "2400.00" } + +[[expect]] +query = "profit_and_loss" +from = "2026-01-01" +to = "2026-07-31" +values = { "4000" = "0.00", "6000" = "2400.00", net_income = "-2400.00" } diff --git a/bookspec/examples/iif-quickbooks-journal-file.toml b/bookspec/examples/iif-quickbooks-journal-file.toml new file mode 100644 index 0000000..483cc3e --- /dev/null +++ b/bookspec/examples/iif-quickbooks-journal-file.toml @@ -0,0 +1,183 @@ +# The journal file QuickBooks Desktop imports (IIF), as of a day. A format, +# not accounting: P13's appendix of interchange formats. +# +# wanderbooks: P3 defects 7 and 21, decision D-36 and the P3 review (the +# file numbered transactions by their place in it, held entries dated after +# the package's as_of, wrote '?' for every character past ASCII, and knew +# nothing of QuickBooks' length limits, its double quote, or its rule that +# a subaccount has its parent's type, and could declare a subaccount +# before its parent); rules.md B-34; regression tests +# `iif_docnum_is_the_entry_id`, +# `iif_writes_plain_ascii_and_lists_every_change`, +# `iif_fits_quickbooks_desktop_limits`, +# `iif_a_subaccount_of_another_type_stands_at_the_top_level`, +# `iif_declares_each_parent_before_its_subaccounts` (ledger-import +# tests/spec_import.rs) and `as_of_package_iif_stops_at_as_of` +# (ledger-package tests/package.rs). +# +# Setup beyond the accounts: `cash_accounts` are the bank accounts (BANK); +# an account's QuickBooks type otherwise follows its type (asset OCASSET, +# liability OCLIAB, equity EQUITY, revenue INC, expense EXP); an account's +# `parent` is its parent's code. Each event is a posted entry, its id one +# more than its index. Expected values: iif_lines gives the file's lines +# in order, each a string of fields joined by tabs ("\t"), every line +# ending CR LF in the file; iif_changes gives each text written +# differently from the books, in file order (the accounts as declared: +# by code, each after its parent; then the entries), as [what, books' +# text, file's text], where what is "name ", "number" (left out of +# ACCNUM when the file's text is empty), "top level " (written +# apart from its parent, the books' text: the parent's code) or +# "memo ". + +id = "iif-quickbooks-journal-file" +appendix = "formats" +title = "A QuickBooks Desktop journal file as of a day" +rule = "An IIF journal file as of a day D MUST declare every account first (NAME, ACCNTTYPE, ACCNUM), then hold one GENERAL JOURNAL transaction per posted entry dated on or before D, period closes excepted, in the order posted: a TRNS line and SPL lines, debits positive and credits negative, dated M/D/YYYY, each numbered (DOCNUM) with the entry's id; then ENDTRNS. It MUST be ASCII text, every line ending CR LF, no field holding a tab, a line break or a double quote; an account name level MUST be at most 31 characters and no two NAMEs alike, an account number at most 7 characters and no two alike (else left out), a memo at most 4,095 characters; each parent MUST be declared before its subaccounts, and a subaccount whose QuickBooks type differs from its parent's MUST be written at the top level. Each text written differently from the books MUST be reported." +status = "AUTHORITY" +sources = [ + "Intuit, IIF Overview: import kit, sample files, and headers: IIF files are ASCII text, tab-separated; an account not already in the company file is created as a new bank account (docs/spec/sources/intuit-iif.md)", + "Intuit, IIF Import Kit, IIF Header Help !TRNS, !SPL, !ACCNT: TRNS and SPL lines then ENDTRNS; \"Debit amounts are always positive, credit amounts are always negative\"; DOCNUM is the transaction's number; a subaccount's NAME its parents' names first, joined by colons (docs/spec/sources/intuit-iif.md)", + "Intuit, IIF Import Kit, 2007 General Journal Entry example file: GENERAL JOURNAL, M/D/YYYY, every line ending CR LF (docs/spec/sources/intuit-iif.md)", + "Intuit, Character limitations for fields in QuickBooks (US, updated 8/3/2026): Account Name 31, Account Numbers 7, Memo 4,095 (docs/spec/sources/intuit-iif.md)", + "Intuit, Add, edit, or delete accounts in QuickBooks Desktop (updated 8/4/2026): \"The parent and subaccount must be the same account type.\" (docs/spec/sources/intuit-iif.md)", + "Reckon, IIF import changes a double quote to a single one by design; J. Clark (2003): QuickBooks uses double quotes as text delimiters (docs/spec/sources/intuit-iif.md; not Intuit)", +] +tags = ["iif", "quickbooks", "as-of", "format", "transliteration"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half_away_from_zero" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1090" +name = "Bank accounts" +type = "asset" + +# A bank account (BANK) under a header that is no bank (OCASSET): written +# at the top level. +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" +parent = "1090" + +[[setup.accounts]] +code = "12345678" +name = "Suspense" +type = "expense" + +[[setup.accounts]] +code = "3000" +name = "Owner capital" +type = "equity" + +[[setup.accounts]] +code = "4000" +name = "Sales" +type = "revenue" + +[[setup.accounts]] +code = "6000" +name = "Café meals" +type = "expense" + +# Coded before its parent: declared after it. +[[setup.accounts]] +code = "5900" +name = "Snacks" +type = "expense" +parent = "6000" + +[[setup.accounts]] +code = "6100" +name = "Professional services and consulting fees" +type = "expense" + +# 0: entry 1. +[[events]] +date = "2026-01-02" +description = "Owner contribution" +lines = [ + { account = "1000", side = "debit", amount = "5000.00" }, + { account = "3000", side = "credit", amount = "5000.00" }, +] + +# 1: entry 2. An em dash and double quotes. +[[events]] +date = "2026-01-15" +description = "Lunch — \"team\"" +lines = [ + { account = "6000", side = "debit", amount = "42.50" }, + { account = "1000", side = "credit", amount = "42.50" }, +] + +# 2: entry 3. An account named past 31 characters. +[[events]] +date = "2026-01-20" +description = "Consulting" +lines = [ + { account = "6100", side = "debit", amount = "300.00" }, + { account = "1000", side = "credit", amount = "300.00" }, +] + +# 3: entry 4, dated the day itself: in. +[[events]] +date = "2026-02-01" +description = "Sale" +lines = [ + { account = "1000", side = "debit", amount = "1000.00" }, + { account = "4000", side = "credit", amount = "1000.00" }, +] + +# 4: entry 5, the day after: out. +[[events]] +date = "2026-02-02" +description = "Second sale" +lines = [ + { account = "1000", side = "debit", amount = "250.00" }, + { account = "4000", side = "credit", amount = "250.00" }, +] + +[[expect]] +query = "iif_lines" +as_of = "2026-02-01" +values = [ + "!ACCNT\tNAME\tACCNTTYPE\tACCNUM", + "ACCNT\tChecking\tBANK\t1000", + "ACCNT\tBank accounts\tOCASSET\t1090", + "ACCNT\tSuspense\tEXP\t", + "ACCNT\tOwner capital\tEQUITY\t3000", + "ACCNT\tSales\tINC\t4000", + "ACCNT\tCafe meals\tEXP\t6000", + "ACCNT\tCafe meals:Snacks\tEXP\t5900", + "ACCNT\tProfessional services and consu\tEXP\t6100", + "!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO", + "!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO", + "!ENDTRNS", + "TRNS\t\tGENERAL JOURNAL\t1/2/2026\tChecking\t\t5000.00\t1\tOwner contribution", + "SPL\t\tGENERAL JOURNAL\t1/2/2026\tOwner capital\t\t-5000.00\t1\tOwner contribution", + "ENDTRNS", + "TRNS\t\tGENERAL JOURNAL\t1/15/2026\tCafe meals\t\t42.50\t2\tLunch -- 'team'", + "SPL\t\tGENERAL JOURNAL\t1/15/2026\tChecking\t\t-42.50\t2\tLunch -- 'team'", + "ENDTRNS", + "TRNS\t\tGENERAL JOURNAL\t1/20/2026\tProfessional services and consu\t\t300.00\t3\tConsulting", + "SPL\t\tGENERAL JOURNAL\t1/20/2026\tChecking\t\t-300.00\t3\tConsulting", + "ENDTRNS", + "TRNS\t\tGENERAL JOURNAL\t2/1/2026\tChecking\t\t1000.00\t4\tSale", + "SPL\t\tGENERAL JOURNAL\t2/1/2026\tSales\t\t-1000.00\t4\tSale", + "ENDTRNS", +] + +[[expect]] +query = "iif_changes" +as_of = "2026-02-01" +values = [ + ["top level 1000", "1090", ""], + ["number", "12345678", ""], + ["name 6000", "Café meals", "Cafe meals"], + ["name 6100", "Professional services and consulting fees", "Professional services and consu"], + ["memo 2", "Lunch — \"team\"", "Lunch -- 'team'"], +] diff --git a/crates/ledger-core/src/form1099.rs b/crates/ledger-core/src/form1099.rs index 514d2b9..cb12fe5 100644 --- a/crates/ledger-core/src/form1099.rs +++ b/crates/ledger-core/src/form1099.rs @@ -171,7 +171,7 @@ pub fn vendor_1099_report_with( threshold: Option, sources: &PaymentAccounts, ) -> Result { - vendor_1099_report_with_members(ledger, year, threshold, sources, &[]) + vendor_1099_report_with_members(ledger, year, None, threshold, sources, &[]) } /// What an entry paid out of `sources`: credits minus debits to them. @@ -211,15 +211,16 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result Result Result, threshold: Option, sources: &PaymentAccounts, members: &[crate::PartyMember], @@ -275,7 +281,7 @@ pub fn vendor_1099_report_with_members( let mut paid: std::collections::HashMap = Default::default(); let mut unresolved = Vec::new(); for entry in ledger.posted_entries() { - if entry.date().year() != year { + if entry.date().year() != year || through.is_some_and(|t| entry.date() > t) { continue; } let Some(vendor) = ledger.entry_vendor(entry.id()) else { diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index 7b34f76..655ad44 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -13,8 +13,10 @@ //! 2026-10-15); IRS truncated TINs: Treas. Reg. §301.6109-4. //! INVARIANTS: //! 1. No party field holds a full taxpayer identification number: any -//! run of nine digits (single spaces or hyphens between them allowed) -//! is refused. A W-9's TIN is kept as its last four digits only. +//! run of nine digits, in any script, separated or not (`has_full_tin`: +//! up to three spaces, hyphens, dots, slashes or underscores, in any +//! of their Unicode forms, between neighbors) is refused. A W-9's TIN +//! is kept as its last four digits only. //! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need //! the employee role. Taxpayer-ID facts (`TinFacts`: the TIN's type, //! last four, and vault record) are allowed for any role: members @@ -272,33 +274,100 @@ pub fn name_key(name: &str) -> String { name.trim().to_lowercase() } -/// The characters that may sit between a TIN's digits: space, hyphen, -/// dot, slash, underscore, and the Unicode dashes and no-break space -/// (`291-12-2846`, `291.12.2846`, `291/12/2846`, `291 - 12 - 2846`). +/// The characters that may sit between a TIN's digits (`291-12-2846`, +/// `291.12.2846`, `291/12/2846`, `291 - 12 - 2846`): in ASCII a space, +/// hyphen, dot, slash or underscore (no other, not a tab or a line +/// break); past ASCII, every form of them Unicode has, so a TIN can't slip +/// through typed in another script (`docs/spec/sources/unicode-tin-text.md`, +/// Unicode 17.0): every space (general category Zs: the no-break, thin and +/// ideographic spaces), every dash (property Dash: the hyphens, en and em +/// dashes, minus sign, Hebrew maqaf, fullwidth and small hyphen-minus), +/// and the characters whose compatibility form is a dot, slash or +/// underscore (the fullwidth and small full stops, the one dot leader, +/// the fullwidth solidus, the fullwidth and presentation low lines). fn tin_separator(c: char) -> bool { + match c { + ' ' | '-' | '.' | '/' | '_' => true, + c if c.is_ascii() => false, + // White_Space less the line breaks (NEL, LS, PS): the Zs spaces. + c if c.is_whitespace() => !matches!(c, '\u{85}' | '\u{2028}' | '\u{2029}'), + c => matches!( + c, + // Dash (PropList.txt), less U+002D above. + '\u{58A}' + | '\u{5BE}' + | '\u{1400}' + | '\u{1806}' + | '\u{2010}'..='\u{2015}' + | '\u{2053}' + | '\u{207B}' + | '\u{208B}' + | '\u{2212}' + | '\u{2E17}' + | '\u{2E1A}' + | '\u{2E3A}'..='\u{2E3B}' + | '\u{2E40}' + | '\u{2E5D}' + | '\u{301C}' + | '\u{3030}' + | '\u{30A0}' + | '\u{FE31}'..='\u{FE32}' + | '\u{FE58}' + | '\u{FE63}' + | '\u{FF0D}' + | '\u{10D6E}' + | '\u{10EAD}' + // NFKC '.', '/', '_' (UnicodeData.txt decompositions). + | '\u{2024}' + | '\u{FE52}' + | '\u{FF0E}' + | '\u{FF0F}' + | '\u{FE33}'..='\u{FE34}' + | '\u{FE4D}'..='\u{FE4F}' + | '\u{FF3F}' + ), + } +} + +/// Whether `c` is invisible, so a TIN's digits read as neighbors across it +/// however many there are (`1\u{200B}2` shows as `12`): Unicode's +/// Default_Ignorable_Code_Point (DerivedCoreProperties.txt, 17.0): the +/// soft hyphen, the zero-width space and joiners, the left-to-right and +/// right-to-left marks and the other directional formatting characters, +/// the word joiner, the byte-order mark, the variation selectors, the tags +/// and the fillers. None is ASCII. +fn tin_invisible(c: char) -> bool { matches!( c, - ' ' | '-' - | '.' - | '/' - | '_' - | '\u{a0}' - | '\u{2010}' - | '\u{2011}' - | '\u{2012}' - | '\u{2013}' - | '\u{2014}' - | '\u{2212}' + '\u{AD}' + | '\u{34F}' + | '\u{61C}' + | '\u{115F}'..='\u{1160}' + | '\u{17B4}'..='\u{17B5}' + | '\u{180B}'..='\u{180F}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{3164}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FEFF}' + | '\u{FFA0}' + | '\u{FFF0}'..='\u{FFF8}' + | '\u{1BCA0}'..='\u{1BCA3}' + | '\u{1D173}'..='\u{1D17A}' + | '\u{E0000}'..='\u{E0FFF}' ) } /// The most separators that may sit between two of a TIN's digits -/// (`" - "` is three). +/// (`" - "` is three); invisible characters ([`tin_invisible`]) don't +/// count. const MAX_TIN_GAP: usize = 3; /// Each run in `chars` that holds nine digits or more, as (first, last) /// char indices of its digits: digits with at most [`MAX_TIN_GAP`] -/// separators ([`tin_separator`]) between neighbors. +/// separators ([`tin_separator`]) between neighbors, and any number of +/// invisible characters. fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> { let mut runs = Vec::new(); let mut i = 0; @@ -315,11 +384,19 @@ fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> { j += 1; continue; } - let gap = chars[j..].iter().take_while(|c| tin_separator(**c)).count(); - if (1..=MAX_TIN_GAP).contains(&gap) - && chars.get(j + gap).is_some_and(|c| c.is_numeric()) + let between = chars[j..] + .iter() + .take_while(|c| tin_separator(**c) || tin_invisible(**c)) + .count(); + let gap = chars[j..j + between] + .iter() + .filter(|c| !tin_invisible(**c)) + .count(); + if between > 0 + && gap <= MAX_TIN_GAP + && chars.get(j + between).is_some_and(|c| c.is_numeric()) { - j += gap; + j += between; } else { break; } @@ -333,11 +410,13 @@ fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> { } /// Whether `text` holds a run of nine digits, with at most three -/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or -/// no-break space) between neighbors: an SSN, EIN, or ITIN however it's -/// written (`291-12-2846`, `291.12.2846`, `291 - 12 - 2846`). Any Unicode -/// numeral counts as a digit (full-width, Arabic-Indic, ...), so a number -/// can't slip through in another script. +/// separators (a space, hyphen, dot, slash or underscore, in any of +/// their Unicode forms) between neighbors, invisible +/// characters not counted: an SSN, EIN, or ITIN however it's written +/// (`291-12-2846`, `291.12.2846`, `291 - 12 - 2846`, `291-12-2846`). +/// Any Unicode numeral counts as a digit (full-width, Arabic-Indic, ...), +/// so a number can't slip through in another script. In ASCII, exactly +/// the space, hyphen, dot, slash and underscore separate. pub fn has_full_tin(text: &str) -> bool { let chars: Vec = text.chars().collect(); !full_tin_runs(&chars).is_empty() diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs index 2c0cad7..79e5286 100644 --- a/crates/ledger-core/tests/form1099.rs +++ b/crates/ledger-core/tests/form1099.rs @@ -365,6 +365,7 @@ fn a_shared_payment_counts_the_vendors_share() { &s.l, 2026, None, + None, &ledger_core::PaymentAccounts { cash: vec![bank], financing: vec![], diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index a523dad..ec28f70 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -177,7 +177,7 @@ fn has_full_tin_truth_table() { ("291\u{2013}12\u{2013}2846", true), // en dashes ("291\u{a0}12\u{a0}2846", true), // no-break spaces ("12 3456789", false), // four separators break the run: 2 + 7 - ("571, 93, 2846", false), // a comma is not a separator + ("291, 12, 2846", false), // a comma is not a separator ("12\t3456789", false), // a tab is not a space ("12345678", false), // eight digits ("123-45-678", false), @@ -241,6 +241,145 @@ fn has_full_tin_unicode_digits() { assert!(has_full_tin("١٢٣٤٥٦٧٨٩")); // Arabic-Indic digits } +/// Oracle: `has_full_tin` doc (P3 review round 2): past ASCII, every form +/// of the five separators Unicode has separates (every Zs space, every +/// Dash character, the compatibility forms of the dot, slash and +/// underscore; `docs/spec/sources/unicode-tin-text.md`, Unicode 17.0), +/// and invisible characters (Default_Ignorable_Code_Point) don't count +/// toward the gap. A TIN typed in fullwidth forms, Arabic-Indic digits, +/// with an en dash, a non-breaking hyphen, a Hebrew maqaf, any space, or +/// with right-to-left marks around its hyphens is still a full TIN; line +/// breaks, commas and a fourth visible separator still end a run. +/// (Before, "123-45-6789" passed: the fullwidth hyphen-minus wasn't +/// a separator, nor the ideographic or thin space.) +#[test] +fn has_full_tin_reads_every_script_and_separator() { + let tins = [ + // Fullwidth digits and hyphen-minus. + "\u{FF11}\u{FF12}\u{FF13}\u{FF0D}\u{FF14}\u{FF15}\u{FF0D}\u{FF16}\u{FF17}\u{FF18}\u{FF19}", + "291\u{FF0D}12\u{FF0D}2846", // fullwidth hyphen-minus + "291\u{FE63}12\u{FE63}2846", // small hyphen-minus + // Arabic-Indic, Extended Arabic-Indic, Devanagari (en dashes). + "\u{661}\u{662}\u{663}-\u{664}\u{665}-\u{666}\u{667}\u{668}\u{669}", + "\u{6F1}\u{6F2}\u{6F3}\u{6F4}\u{6F5}\u{6F6}\u{6F7}\u{6F8}\u{6F9}", + "\u{967}\u{968}\u{969}\u{2013}\u{96A}\u{96B}\u{2013}\u{96C}\u{96D}\u{96E}\u{96F}", + "291\u{2013}12\u{2013}2846", // en dash + "291\u{2011}12\u{2011}2846", // non-breaking hyphen + "291\u{2012}12\u{2012}2846", // figure dash + "291\u{2015}12\u{2015}2846", // horizontal bar + "291\u{5BE}12\u{5BE}2846", // Hebrew maqaf + "291\u{301C}12\u{301C}2846", // wave dash + "291\u{A0}12\u{A0}2846", // no-break space + "291\u{3000}12\u{3000}2846", // ideographic space + "291\u{2009}12\u{2009}2846", // thin space + "291\u{202F}12\u{202F}2846", // narrow no-break space + "291 \u{2014} 12 \u{2014} 2846", // three: space, em dash, space + "291\u{FF0E}12\u{FF0E}2846", // fullwidth full stop + "291\u{2024}12\u{2024}2846", // one dot leader + "291\u{FF0F}12\u{FF0F}2846", // fullwidth solidus + "291\u{FF3F}12\u{FF3F}2846", // fullwidth low line + // Zero-width spaces between every digit. + "2\u{200B}9\u{200B}1\u{200B}1\u{200B}2\u{200B}2\u{200B}8\u{200B}4\u{200B}6", + "291\u{200F}-\u{200F}12\u{200F}-\u{200F}2846", // right-to-left marks around hyphens + "291\u{200E}\u{200E}\u{200E}\u{200E} 12 2846", // invisible marks don't count + "291\u{AD}12\u{AD}2846", // soft hyphens + "291\u{2060}122846", // word joiner + "\u{FEFF}291122846", + ]; + for t in tins { + assert!(has_full_tin(t), "has_full_tin({t:?})"); + let masked = redact_full_tins(t); + assert!(!has_full_tin(&masked), "{t:?} masked: {masked:?}"); + assert_eq!( + masked.chars().count(), + t.chars().count(), + "only digits change: {masked:?}" + ); + } + assert_eq!( + redact_full_tins( + "ref \u{FF11}\u{FF12}\u{FF13}\u{FF0D}\u{FF14}\u{FF15}\u{FF0D}\u{FF16}\u{FF17}\u{FF18}\u{FF19}" + ), + "ref •••\u{FF0D}••\u{FF0D}••••" + ); + assert_eq!( + redact_full_tins("291\u{200F}-\u{200F}12-2846 ok"), + "•••\u{200F}-\u{200F}••-•••• ok" + ); + let not = [ + "12\u{3000}\u{3000}\u{3000}\u{3000}3456789", // four visible separators + "12 \u{2014} \u{200B}-3456789", // four, an invisible one among them + "291\u{2028}12\u{2028}2846", // line separators end a run + "291\u{2029}12\u{2029}2846", // paragraph separators + "291\u{85}12\u{85}2846", // next line + "291\u{FF0C}12\u{FF0C}2846", // fullwidth commas + "291\u{3001}12\u{3001}2846", // ideographic commas + "\u{FF11}\u{FF12}\u{FF13}\u{FF14}\u{FF15}\u{FF16}\u{FF17}\u{FF18}", // eight digits + "291\u{200B}12284", // eight, invisibly joined + ]; + for t in not { + assert!(!has_full_tin(t), "has_full_tin({t:?})"); + } +} + +/// The reading before P3 review round 2, the oracle for ASCII: past it, +/// only the no-break space and a few dashes separated, and every +/// separator counted. +fn has_full_tin_before(text: &str) -> bool { + let sep = |c: char| { + matches!( + c, + ' ' | '-' | '.' | '/' | '_' | '\u{a0}' | '\u{2010}'..='\u{2014}' | '\u{2212}' + ) + }; + let chars: Vec = text.chars().collect(); + let mut i = 0; + while i < chars.len() { + if !chars[i].is_numeric() { + i += 1; + continue; + } + let (mut digits, mut last, mut j) = (1, i, i + 1); + while j < chars.len() { + if chars[j].is_numeric() { + digits += 1; + last = j; + j += 1; + continue; + } + let gap = chars[j..].iter().take_while(|c| sep(**c)).count(); + if (1..=3).contains(&gap) && chars.get(j + gap).is_some_and(|c| c.is_numeric()) { + j += gap; + } else { + break; + } + } + if digits >= 9 { + return true; + } + i = last + 1; + } + false +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + + /// `has_full_tin` reads ASCII exactly as it did (the fix only widens + /// what separates past ASCII), and what it found before in any text + /// it still finds: it only ever finds more. + #[test] + fn has_full_tin_keeps_its_ascii_reading( + ascii in "[0-9 ./_\t\n\r,:a-]{0,40}", + any in "[0-9 ./_\t\n,a\u{A0}\u{2010}-\u{2015}\u{2212}\u{FF0D}\u{3000}\u{200B}\u{200F}\u{FF10}-\u{FF19}\u{660}-\u{669}\u{2028}-]{0,40}", + ) { + prop_assert_eq!(has_full_tin(&ascii), has_full_tin_before(&ascii), "{:?}", ascii); + if has_full_tin_before(&any) { + prop_assert!(has_full_tin(&any), "{:?}", any); + } + } +} + // ---------------------------------------------------------------- check_text /// Oracle: `check_text` doc: not a full TIN, at most 500 characters, no diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index eeb226a..d042bed 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -1823,9 +1823,15 @@ impl Books { cash: self.money_cash_accounts.clone(), financing: self.financing_accounts.clone(), }; - let r = - ledger_core::vendor_1099_report_with_members(l, year, None, &sources, &self.members) - .ok()?; + let r = ledger_core::vendor_1099_report_with_members( + l, + year, + None, + None, + &sources, + &self.members, + ) + .ok()?; let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone(); Some((line, r.threshold)) } diff --git a/crates/ledger-gui/src/menu.rs b/crates/ledger-gui/src/menu.rs index b2808a4..8a253b1 100644 --- a/crates/ledger-gui/src/menu.rs +++ b/crates/ledger-gui/src/menu.rs @@ -179,12 +179,20 @@ impl Job { )?; let (stem, ext) = crate::export::split_name(&p.file_name); let path = crate::export::save(stem, ext, &p.zip)?; - let note = (!p.tin_gaps.is_empty()).then(|| { - format!( + let mut notes = Vec::new(); + if !p.tin_gaps.is_empty() { + notes.push(format!( "{} TIN(s) aren't in it (the README says which): ask your agent to collect them.", p.tin_gaps.len() - ) - }); + )); + } + if p.iif_changes > 0 { + notes.push(format!( + "{} account name(s), number(s), place(s) or memo(s) are written differently in its QuickBooks file, to fit QuickBooks Desktop: the README says how.", + p.iif_changes + )); + } + let note = (!notes.is_empty()).then(|| notes.join(" ")); Ok((path, note)) } Job::Worksheet(year) => { diff --git a/crates/ledger-gui/src/theme.rs b/crates/ledger-gui/src/theme.rs index e762b62..b1c19ad 100644 --- a/crates/ledger-gui/src/theme.rs +++ b/crates/ledger-gui/src/theme.rs @@ -117,10 +117,21 @@ pub fn bold(size: f32) -> FontId { FontId::new(size, FontFamily::Name(BOLD.into())) } -/// Install fonts: Inter with tabular figures frozen in, so every number in -/// the app lines up digit for digit. Phosphor icons ride along as a -/// fallback in every family. +/// Install fonts ([`font_definitions`]). pub fn install_fonts(ctx: &Context) { + ctx.set_fonts(font_definitions()); +} + +/// The viewer's fonts: Inter with tabular figures frozen in, so every +/// number in the app lines up digit for digit; egui's own fonts and +/// Phosphor's icons as fallbacks in every family; then the PDFs' Noto +/// fallbacks (`ledger_import::fonts::FALLBACKS`: Chinese, Japanese, +/// Korean, Arabic, Hebrew, Thai, Devanagari), the very bytes the PDFs use, +/// so a name in any of those scripts shows as written, never as empty +/// boxes, and no font is in the binary twice. (egui shapes each word, +/// joining Arabic and setting it right to left, but doesn't reorder the +/// words of a right-to-left phrase: they read left to right.) +pub fn font_definitions() -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { fonts @@ -166,7 +177,13 @@ pub fn install_fonts(ctx: &Context) { fonts .families .insert(FontFamily::Name(BOLD.into()), family("InterTab-Bold")); - ctx.set_fonts(fonts); + for (name, bytes) in &ledger_import::fonts::FALLBACKS { + add(&mut fonts, name, bytes); + for list in fonts.families.values_mut() { + list.push((*name).to_owned()); + } + } + fonts } /// Apply the viewer's own style for light or dark mode (tests). @@ -255,3 +272,66 @@ pub fn apply_scheme(ctx: &Context, scheme: &crate::schemes::Scheme) { style.interaction.selectable_labels = false; }); } + +#[cfg(test)] +mod tests { + use super::*; + + /// font_definitions: every family ends with the PDFs' Noto fallbacks, + /// in their order, after Inter, egui's fonts and the icons, each font + /// the very bytes ledger-import bundles (the same pointer: one copy in + /// the binary); and a name in each script shows with real glyphs in + /// every family the viewer uses. + #[test] + fn every_family_falls_back_to_the_noto_fonts() { + let defs = font_definitions(); + let names: Vec = ledger_import::fonts::FALLBACKS + .iter() + .map(|(n, _)| (*n).to_owned()) + .collect(); + for family in [ + FontFamily::Proportional, + FontFamily::Monospace, + FontFamily::Name(SEMIBOLD.into()), + FontFamily::Name(BOLD.into()), + ] { + let list = &defs.families[&family]; + assert!(list.len() > names.len(), "{family:?}: {list:?}"); + assert_eq!(list[list.len() - names.len()..], names[..], "{family:?}"); + } + assert!(defs.families[&FontFamily::Proportional][0] == "InterTab-Regular"); + for (name, bytes) in &ledger_import::fonts::FALLBACKS { + let data: &[u8] = &defs.font_data[*name].font; + assert!( + std::ptr::eq(data, *bytes), + "{name} is ledger-import's own bytes, not a copy" + ); + } + let ctx = Context::default(); + ctx.set_fonts(defs); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + for text in [ + "北京 東京 서울", + "مكتب الرياض", + "שלום עולם", + "สวัสดีครับ", + "नमस्ते", + ] { + for id in [ + regular(14.0), + semibold(14.0), + bold(14.0), + FontId::monospace(12.5), + ] { + // Letters only: egui says a character the replacement + // glyph's own font draws is missing, the space in Hack. + let missing: Vec = text + .chars() + .filter(|c| !c.is_whitespace() && !ctx.fonts_mut(|f| f.has_glyph(&id, *c))) + .collect(); + assert!(missing.is_empty(), "{text} in {id:?}: {missing:?}"); + } + } + } +} diff --git a/crates/ledger-gui/tests/exports.rs b/crates/ledger-gui/tests/exports.rs index 32ae3f7..d55c68c 100644 --- a/crates/ledger-gui/tests/exports.rs +++ b/crates/ledger-gui/tests/exports.rs @@ -250,6 +250,12 @@ fn the_menu_makes_the_cpa_package_with_full_tins() { .read_to_string(&mut readme) .unwrap(); assert!(readme.starts_with("Acme LLC"), "{readme}"); + // The QuickBooks file stops at the package's day, like every file. + assert!( + readme + .contains("general-journal.iif: every posted entry from the first through 2026-10-15,"), + "{readme}" + ); // The member's K-1 row carries their full SSN, from the book's vault. let mut k1 = String::new(); zip.by_name("k1-members.csv") diff --git a/crates/ledger-gui/tests/scripts.rs b/crates/ledger-gui/tests/scripts.rs new file mode 100644 index 0000000..c588818 --- /dev/null +++ b/crates/ledger-gui/tests/scripts.rs @@ -0,0 +1,100 @@ +//! Names in other scripts show as written (P3 review round 2): the viewer +//! falls back on the PDFs' own Noto fonts (`ledger_import::fonts`), so an +//! account named in Chinese, Arabic, Hebrew, Thai or Hindi is drawn with +//! its own letters, never as empty boxes. +//! +//! ```text +//! GOAL: the owner reads every name the books hold, in any script. +//! SOURCE: DECISION (lead, P3 review round 2, 2026-10-20). +//! ``` +//! +//! Set `SHOT_DIR` to also save the Accounts page as `scripts.png` there, +//! to look at. + +use egui_kittest::Harness; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_gui::nav::Route; +use ledger_gui::{App, Source, data}; +use ledger_store::Store; +use rust_decimal::Decimal; +use std::path::PathBuf; + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// The names, each its own script. +const NAMES: [&str; 5] = ["北京 办公室", "مكتب الرياض", "שכר דירה", "ค่าเช่า", "किराया"]; + +#[test] +fn account_names_in_other_scripts_show_as_written() { + let dir = + TestDir(std::env::temp_dir().join(format!("ledger-gui-scripts-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&dir.0); + let data_dir = dir.0.join("books-data"); + let mut s = Store::create(&data_dir, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + // Each account with a payment on it, so the page lists it. + for (i, name) in NAMES.iter().enumerate() { + let id = s + .add_account(&format!("6{i}00"), name, AccountType::Expense, None, false) + .unwrap(); + let day = chrono::NaiveDate::from_ymd_opt(2026, 10, 1 + i as u32).unwrap(); + let e = s.create_entry(day, name).unwrap(); + let m = Money::new(Decimal::new(10_000 * (i as i64 + 1), 2), Currency::USD); + s.add_line(e, id, Side::Debit, m).unwrap(); + s.add_line(e, cash, Side::Credit, m).unwrap(); + s.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None) + .unwrap(); + s.post_entry(e).unwrap(); + } + drop(s); + let books = data::load_once(&Source { + data_dir, + today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 20).unwrap()), + }) + .unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(1100.0, 640.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Accounts)); + h.run_steps(4); + let ctx = h.ctx.clone(); + h.state_mut().settle(&ctx); + h.run_steps(4); + if let Ok(out) = std::env::var("SHOT_DIR") { + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + h.render().unwrap().save(out.join("scripts.png")).unwrap(); + } + // Every text the page painted. + fn walk(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut painted = Vec::new(); + for c in &h.output().shapes { + walk(&c.shape, &mut painted); + } + for name in NAMES { + assert!( + painted.iter().any(|t| t.contains(name)), + "{name} is on the Accounts page: {painted:?}" + ); + for c in name.chars().filter(|c| !c.is_whitespace()) { + assert!( + ctx.fonts_mut(|f| f.has_glyph(&egui::FontId::proportional(14.0), c)), + "{c} of {name} has a glyph" + ); + } + } +} diff --git a/crates/ledger-import/Cargo.toml b/crates/ledger-import/Cargo.toml index af9ebcd..5900683 100644 --- a/crates/ledger-import/Cargo.toml +++ b/crates/ledger-import/Cargo.toml @@ -18,10 +18,17 @@ typst = "0.15.1" serde_json = "1" typst-layout = "0.15.1" qrcode = { version = "0.14.1", default-features = false, features = ["svg"] } +# BSD-3-Clause: the IIF's transliteration to ASCII (iif.rs, rules.md B-34). +# Pinned exactly: a new table would change what a CPA's file says. +deunicode = { version = "=1.6.0", default-features = false } [lints] workspace = true [dev-dependencies] proptest = "1" +# The viewer's PDF reader (ledger-gui's hayro): reads back the text a PDF +# draws, glyph by glyph, in the tests. +hayro-interpret = "0.8" +kurbo = "0.13" rqrr = { version = "0.11.0", default-features = false } diff --git a/crates/ledger-import/fonts/NotoSans-LICENSE.txt b/crates/ledger-import/fonts/NotoSans-LICENSE.txt new file mode 100644 index 0000000..3eb6ae5 --- /dev/null +++ b/crates/ledger-import/fonts/NotoSans-LICENSE.txt @@ -0,0 +1,144 @@ +Noto Sans Arabic UI, Noto Sans Hebrew, Noto Sans Thai and Noto Sans +Devanagari, Regular (the PDFs' and the viewer's fallbacks for Arabic, +Hebrew, Thai and Devanagari text, which Inter lacks), unmodified: + + NotoSansArabicUI-Regular.ttf Version 2.011 190,944 bytes + Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic) + SHA-256 f4fe1866a64cba1e8b8d08a67cd29070f919edf778ca5de983c1f2a690fc953b + NotoSansHebrew-Regular.ttf Version 3.001 16,836 bytes + Copyright 2024 The Noto Project Authors (https://github.com/notofonts/hebrew) + SHA-256 04272f5600d0ec816d31d0df73b23aa8d3501ea359ebe820da31c11ffcf00853 + NotoSansThai-Regular.ttf Version 2.002 20,960 bytes + Copyright 2022 The Noto Project Authors (https://github.com/notofonts/thai) + SHA-256 d4303fe9c63ebb72759ca8b6d2040c8ae81689f7d08d7b91c656154382b49313 + NotoSansDevanagari-Regular.ttf Version 2.007 184,228 bytes + Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari) + SHA-256 9c7d935139ea6a1e6ad9dbac4f6d27ece1e04bca8123c8888d00a0f9df4724cd + +Noto is a trademark of Google LLC. (These fonts name no Reserved Font +Name.) + +Each is the static, unhinted Regular weight as the Noto project +publishes it (https://github.com/notofonts/notofonts.github.io), at the +last commit that changed it, retrieved 2026-10-20: + + fonts/NotoSansArabicUI/unhinted/ttf/NotoSansArabicUI-Regular.ttf + commit 1ed0aaad710107ac0d6663ece26a4bb82ce6cff8 + fonts/NotoSansHebrew/unhinted/ttf/NotoSansHebrew-Regular.ttf + commit 40ae54ff25c9e0ad997f62fde48297dd9dfe51c8 + fonts/NotoSansThai/unhinted/ttf/NotoSansThai-Regular.ttf + commit 6da9d59fd18f429cb8dbfa87e87669204f77ddab + fonts/NotoSansDevanagari/unhinted/ttf/NotoSansDevanagari-Regular.ttf + commit 92d2ea744479d3379927f1fb5f8b0945dd0349b7 + + https://raw.githubusercontent.com/notofonts/notofonts.github.io// + +Arabic in its UI form, not Noto Sans Arabic itself (2.013): the same +design and project, but 2.013 draws a letter's dots as marks of their +own, which the PDFs' typesetter (Typst 0.15) writes as text runs of +their own, each carrying the letter again, so a PDF's text (copied, +searched, read aloud) would say "شش" for "ش" and "سلام" as "شلام"; the +UI form, whose letters keep their dots and whose height fits a Latin +line, reads back as written. + +Unhinted, not hinted: the outlines are the same, the hinting tables +(about 180 KB across the four) serve only Windows' old rasterizer, and +neither the PDFs nor the viewer use them. Kept whole: Arabic and +Devanagari need their shaping tables to join and order their letters. + +Emoji are not bundled: a colour emoji font is about 10 MB, so an emoji +in a name prints as an empty box in the PDFs (the viewer draws egui's +own small emoji set). + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/ledger-import/fonts/NotoSansArabicUI-Regular.ttf b/crates/ledger-import/fonts/NotoSansArabicUI-Regular.ttf new file mode 100644 index 0000000..06d3716 Binary files /dev/null and b/crates/ledger-import/fonts/NotoSansArabicUI-Regular.ttf differ diff --git a/crates/ledger-import/fonts/NotoSansCJK-LICENSE.txt b/crates/ledger-import/fonts/NotoSansCJK-LICENSE.txt new file mode 100644 index 0000000..6e25c60 --- /dev/null +++ b/crates/ledger-import/fonts/NotoSansCJK-LICENSE.txt @@ -0,0 +1,125 @@ +Noto Sans CJK SC Regular, version 2.004, subset: +NotoSansCJKsc-Regular-subset.otf (the PDFs' fallback for Chinese, +Japanese and Korean text, and the other characters below that Inter +lacks). + +Copyright (c) 2014-2021 Adobe (http://www.adobe.com/). +Noto is a trademark of Google Inc. +(Noto Sans CJK names no Reserved Font Name.) + +Made from Noto Sans CJK 2.004 (https://github.com/notofonts/noto-cjk), +NotoSansCJK-Regular.ttc as Arch Linux packages it (noto-fonts-cjk +20240730-1; SHA-256 +b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a), +face 2 (Noto Sans CJK SC), with fontTools 4.65.0, 2026-10-20: + + every code point at or above U+1100 that face maps and Inter Regular + (Inter-Regular.ttf here) doesn't: 44,128 code points, among them all + of Hangul, kana, bopomofo, CJK punctuation and fullwidth forms, and + every CJK ideograph the face draws (20,976 of the basic block, 6,582 + of Extension A, the compatibility ideographs, and 2,310 from + Extensions B to G), written as U+XXXX-YYYY ranges to cjk.txt, then + + pyftsubset NotoSansCJK-Regular.ttc --font-number=2 \ + --unicodes-file=cjk.txt --no-hinting --layout-features='' \ + --drop-tables+=BASE,VORG,vhea,vmtx,GPOS,GSUB \ + --output-file=NotoSansCJKsc-Regular-subset.otf + + SHA-256 of the result: + ebf572d736e30b54bf5db087b15f323c16c465052aec788d9cc1d8db41884929 + +The subset is a Modified Version under the license below, which it +keeps. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/ledger-import/fonts/NotoSansCJKsc-Regular-subset.otf b/crates/ledger-import/fonts/NotoSansCJKsc-Regular-subset.otf new file mode 100644 index 0000000..ff09541 Binary files /dev/null and b/crates/ledger-import/fonts/NotoSansCJKsc-Regular-subset.otf differ diff --git a/crates/ledger-import/fonts/NotoSansDevanagari-Regular.ttf b/crates/ledger-import/fonts/NotoSansDevanagari-Regular.ttf new file mode 100644 index 0000000..dfec2d7 Binary files /dev/null and b/crates/ledger-import/fonts/NotoSansDevanagari-Regular.ttf differ diff --git a/crates/ledger-import/fonts/NotoSansHebrew-Regular.ttf b/crates/ledger-import/fonts/NotoSansHebrew-Regular.ttf new file mode 100644 index 0000000..6c71d56 Binary files /dev/null and b/crates/ledger-import/fonts/NotoSansHebrew-Regular.ttf differ diff --git a/crates/ledger-import/fonts/NotoSansThai-Regular.ttf b/crates/ledger-import/fonts/NotoSansThai-Regular.ttf new file mode 100644 index 0000000..d2f9f82 Binary files /dev/null and b/crates/ledger-import/fonts/NotoSansThai-Regular.ttf differ diff --git a/crates/ledger-import/src/asset_register.rs b/crates/ledger-import/src/asset_register.rs index 3b85b32..604f4ac 100644 --- a/crates/ledger-import/src/asset_register.rs +++ b/crates/ledger-import/src/asset_register.rs @@ -46,7 +46,7 @@ //! SOURCE (7-9): docs/spec/rules.md B-25; //! docs/spec/sources/irs-partnership-basis.md. -use crate::pdf::{INTER, PdfError}; +use crate::pdf::PdfError; use crate::report::{ReportMeta, amount_parts, format_amount, long_date}; use crate::xlsx::{Formats, XlsxError, footer, header, number}; use ledger_core::TaxBasisRule; @@ -512,7 +512,7 @@ fn engine() -> &'static TypstEngine { ENGINE.get_or_init(|| { TypstEngine::builder() .main_file(TEMPLATE) - .fonts(INTER) + .fonts(crate::fonts::typst_fonts()) .build() }) } diff --git a/crates/ledger-import/src/fonts.rs b/crates/ledger-import/src/fonts.rs new file mode 100644 index 0000000..7b73ff8 --- /dev/null +++ b/crates/ledger-import/src/fonts.rs @@ -0,0 +1,111 @@ +//! The fonts the books print in, bundled so nothing depends on what's +//! installed: Inter for the text, and a Noto fallback for each script +//! Inter lacks, so a vendor or an account named in Chinese, Japanese, +//! Korean, Arabic, Hebrew, Thai or Hindi prints as written, never as +//! empty boxes. Each font's provenance and license is in `fonts/` +//! (`Inter-LICENSE.txt`, `NotoSansCJK-LICENSE.txt`, `NotoSans-LICENSE.txt`; +//! all SIL OFL 1.1). +//! +//! ```text +//! GOAL: every name prints as the books keep it, in the PDFs and in the +//! viewer, from one copy of each font in the binary. +//! SOURCE: DECISION (lead, P3 review round 2, 2026-10-20: CJK, Arabic, +//! Hebrew, Thai and Devanagari; no emoji, a colour emoji font +//! being about 10 MB). +//! INVARIANTS: +//! 1. The PDFs (crate::pdf, crate::asset_register, crate::labels) set +//! their text in Inter, then try FALLBACKS in order, as each template +//! names them (`font: ("Inter", "Noto Sans CJK SC", ...)`). +//! 2. The viewer (ledger-gui) registers FALLBACKS, these same bytes, +//! after its own fonts: each font is in the binary once. +//! ``` + +/// Inter, the PDFs' text face, in the three weights the templates use. +pub static INTER: [&[u8]; 3] = [ + include_bytes!("../fonts/Inter-Regular.ttf"), + include_bytes!("../fonts/Inter-SemiBold.ttf"), + include_bytes!("../fonts/Inter-Bold.ttf"), +]; + +/// The fallbacks for the scripts Inter lacks, each its family name and +/// its bytes, in the order tried: Noto Sans CJK SC (a subset: every +/// character from U+1100 on that the face draws and Inter doesn't: Hangul, +/// kana, the CJK ideographs, fullwidth forms), Noto Sans Arabic UI (its +/// letters keep their dots, so a PDF's text reads back as written: +/// `fonts/NotoSans-LICENSE.txt`), Noto Sans Hebrew, Noto Sans Thai and +/// Noto Sans Devanagari (each whole, its static Regular weight). Emoji +/// have none. +pub static FALLBACKS: [(&str, &[u8]); 5] = [ + ( + "Noto Sans CJK SC", + include_bytes!("../fonts/NotoSansCJKsc-Regular-subset.otf"), + ), + ( + "Noto Sans Arabic UI", + include_bytes!("../fonts/NotoSansArabicUI-Regular.ttf"), + ), + ( + "Noto Sans Hebrew", + include_bytes!("../fonts/NotoSansHebrew-Regular.ttf"), + ), + ( + "Noto Sans Thai", + include_bytes!("../fonts/NotoSansThai-Regular.ttf"), + ), + ( + "Noto Sans Devanagari", + include_bytes!("../fonts/NotoSansDevanagari-Regular.ttf"), + ), +]; + +/// Every font the PDFs use (INVARIANT 1), as Typst takes them: the +/// bundled bytes themselves, not a copy of them. +pub(crate) fn typst_fonts() -> Vec { + INTER + .iter() + .copied() + .chain(FALLBACKS.iter().map(|(_, bytes)| *bytes)) + .map(typst::foundations::Bytes::new) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// INVARIANT 1: every template names Inter, then every fallback, in + /// FALLBACKS' order; and each font's family is the one it is named + /// by. + #[test] + fn every_template_names_every_fallback_in_order() { + let mut want = String::from("font: (\"Inter\""); + for (family, _) in &FALLBACKS { + want.push_str(&format!(", \"{family}\"")); + } + want.push(')'); + for (name, t) in [ + ("statement.typ", include_str!("../templates/statement.typ")), + ( + "asset_register.typ", + include_str!("../templates/asset_register.typ"), + ), + ("labels.typ", include_str!("../templates/labels.typ")), + ] { + assert!(t.contains(&want), "{name} names {want}"); + } + let book = typst::text::FontBook::from_fonts( + typst_fonts() + .into_iter() + .flat_map(|b| typst::text::Font::iter(b).collect::>()) + .collect::>() + .iter(), + ); + for (family, _) in &FALLBACKS { + assert!( + book.contains_family(&family.to_lowercase()), + "{family} is a family the fonts hold" + ); + } + assert!(book.contains_family("inter")); + } +} diff --git a/crates/ledger-import/src/iif.rs b/crates/ledger-import/src/iif.rs index 5ee8bd8..2ac4fcc 100644 --- a/crates/ledger-import/src/iif.rs +++ b/crates/ledger-import/src/iif.rs @@ -3,26 +3,65 @@ //! //! ```text //! GOAL: hand a CPA's QuickBooks Desktop the posted entries, landing on the -//! right accounts, with nothing invented. +//! right accounts, with nothing invented and nothing changed without +//! saying so. //! SOURCE: docs/spec/sources/intuit-iif.md (Intuit's IIF Overview, the IIF -//! Import Kit's header help and its 2007+ General Journal example). +//! Import Kit's header help and its 2007+ General Journal example); +//! rules.md B-34; Intuit's "Character limitations for fields in +//! QuickBooks" (account name 31, account number 7, memo 4,095); +//! DECISION (owner, 2026-10-20, plan D-36: plain ASCII, +//! transliterated and listed; CR LF line endings). //! INVARIANTS: -//! 1. Accounts MUST be declared first (`!ACCNT`: NAME, ACCNTTYPE, ACCNUM) -//! BECAUSE QuickBooks creates an unknown account "as a new bank -//! account", and the kit says lists come before transactions. +//! 1. Accounts MUST be declared first (`!ACCNT`: NAME, ACCNTTYPE, ACCNUM), +//! each after its parent, BECAUSE QuickBooks creates an unknown +//! account "as a new bank account", and the kit says lists come +//! before what uses them. //! 2. A subaccount's NAME MUST be its path, highest level first, joined by //! colons; a colon inside one account's own name is replaced, or it -//! would read as another level. +//! would read as another level. Each level is trimmed, "?" if nothing +//! is left, and at most [`NAME_MAX`] (31) characters, cut there: +//! QuickBooks Desktop holds no longer account name (Intuit's limits), +//! and a longer one, cut when its chart is imported but not in its +//! transactions (a QuickBooks reseller's note), would miss them. A +//! subaccount MUST have its parent's type ("The parent and subaccount +//! must be the same account type", Intuit), so one whose type differs +//! is written at the top level, its own subaccounts with it. //! 3. Each transaction is a TRNS line, its SPL lines, then ENDTRNS, all //! `GENERAL JOURNAL`, dated M/D/YYYY (the kit's 2007+ example), with //! debits positive and credits negative, so the lines sum to zero. -//! 4. Fields MUST hold no tab or line break (no escape exists) and only -//! ASCII (IIF is ASCII text): common punctuation is transliterated, -//! anything else becomes '?'. +//! DOCNUM ("the transaction's number") is the entry's id in the books, +//! on the TRNS and every SPL, so the CPA can trace it. A MEMO is at +//! most [`MEMO_MAX`] (4,095) characters, cut there. +//! 4. The file is ASCII text (IIF "is ASCII text") and every line ends +//! CR LF (the kit's example file). A field holds no tab or line break +//! (no escape exists): each becomes a space, any other control +//! character '?', a double quote (QuickBooks reads it as a text +//! delimiter) a single one, and a character past ASCII its +//! transliteration (deunicode: "René" is "Rene", "北京" is "Bei Jing"), +//! or '?' when it has none. //! 5. NEVER write an entry whose account wasn't declared: refuse instead. +//! 6. NEVER give two accounts one NAME (QuickBooks identifies an account +//! by its NAME, so it would merge them): [`iif_accounts`] follows the +//! own name of each account whose path would match another's, +//! ignoring case, with " (its code)", until none matches, and +//! [`journal_to_iif`] refuses a match that remains. NEVER give two +//! accounts one ACCNUM, nor one longer than [`NUMBER_MAX`] (7, QuickBooks +//! Desktop's longest): such a number is left out (ACCNUM is optional; +//! the NAME identifies the account). +//! 7. Nothing changes silently: every account name, number or place and +//! every memo the file writes differently from the books is in +//! [`Iif::changes`], and [`Iif::warnings`] says each, one line each +//! (the memos grouped by what is written differently), never with a +//! full TIN in it. +//! 8. The file names no customer or vendor (no NAME column): Intuit +//! states no such need for an IIF journal, though QuickBooks Desktop +//! asks for one on a journal entry to accounts receivable or payable +//! and takes one such account a transaction (Intuit staff and help; +//! sources/intuit-iif.md). [`Iif::receivable_payable`] lists the +//! entries with such a line, for the human to be told. //! ``` //! -//! Layout (each `→` is a tab): +//! Layout (each `→` is a tab; every line ends CR LF): //! //! ```text //! !ACCNT→NAME→ACCNTTYPE→ACCNUM @@ -34,13 +73,35 @@ //! SPL→→GENERAL JOURNAL→1/15/2026→Revenue→→-1000.00→1→Sale //! ENDTRNS //! ``` +//! +//! (DOCNUM 1: the sale is entry 1 in its books.) use chrono::Datelike; -use ledger_core::{AccountId, JournalEntry, Ledger, Side}; +use deunicode::AsciiChars; +use ledger_core::parties::{has_full_tin, redact_full_tins}; +use ledger_core::{AccountId, EntryId, JournalEntry, Ledger, Side}; use rust_decimal::Decimal; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::fmt; use thiserror::Error; +/// How every line of the file ends (INVARIANT 4: CR LF, as Intuit's own +/// example file). +const EOL: &str = "\r\n"; + +/// The most characters of an account's own name, one level of its NAME, +/// that QuickBooks Desktop holds (Intuit, "Character limitations for +/// fields in QuickBooks": Account Name 31; sources/intuit-iif.md). +pub const NAME_MAX: usize = 31; + +/// The most characters of an account number (ACCNUM) QuickBooks Desktop +/// holds (the same table: Account Numbers 7). +pub const NUMBER_MAX: usize = 7; + +/// The most characters of a memo QuickBooks Desktop holds (the same +/// table: Memo 4,095). +pub const MEMO_MAX: usize = 4095; + /// A QuickBooks account type: the IIF `ACCNTTYPE` keywords (Intuit's IIF /// Import Kit, `!ACCNT` header help). Wanderbooks' five types don't say /// which of these an account is (checking is `BANK`, not "other current @@ -102,18 +163,241 @@ impl QbAccountType { } } -/// One account as QuickBooks will know it. +/// One account as QuickBooks will know it, and as the books keep it. #[derive(Clone, Debug, PartialEq)] pub struct IifAccount { /// The wanderbooks account. pub id: AccountId, - /// Its name with its parents', highest first, joined by colons, each - /// already made safe for IIF ([`iif_accounts`] builds these). + /// Its NAME in the file: its own name with its parents', highest + /// first, joined by colons, each already made safe for IIF + /// ([`iif_accounts`] builds these). pub path: String, - /// Its account number (ACCNUM): the wanderbooks code. + /// Its account number: the wanderbooks code made safe for IIF and + /// trimmed. [`journal_to_iif`] writes it as ACCNUM, or leaves it out + /// when QuickBooks can't hold it (INVARIANT 6). pub number: String, /// Its QuickBooks type. pub qb_type: QbAccountType, + /// Its own name as the books keep it (for [`Iif::changes`]). + pub name: String, + /// Its code as the books keep it (for [`Iif::changes`]). + pub code: String, + /// Its parent in the books when the file writes it apart, at the top + /// level, because QuickBooks would give it its parent's type + /// (INVARIANT 2): the parent's code and QuickBooks type. + pub apart_from: Option<(String, QbAccountType)>, +} + +/// What [`journal_to_iif`] writes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Iif { + /// The file: ASCII text, every line ending CR LF. + pub text: String, + /// Each account name, number or place and each memo the file writes + /// differently from the books (INVARIANT 7), in file order: the + /// accounts as given, then the entries as given. + pub changes: Vec, + /// The entries with a line on an accounts receivable or accounts + /// payable account, in file order: the file names no customer or + /// vendor on those lines (INVARIANT 8). + pub receivable_payable: Vec, +} + +/// One text the file writes differently from the books. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IifChange { + /// Which text. + pub field: IifField, + /// The books' text. + pub books: String, + /// What the file says (empty for a number left out). + pub written: String, +} + +/// Which text an [`IifChange`] is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IifField { + /// An account's own name: its level of the NAME. + AccountName { + /// The account's code in the books. + code: String, + }, + /// An account's number (ACCNUM): its code in the books, written + /// differently. + AccountNumber, + /// An account's number left out of the file: ACCNUM is blank + /// (INVARIANT 6). + AccountNumberLeftOut { + /// Why. + why: LeftOut, + }, + /// A subaccount written at the top level, apart from its parent (the + /// change's `books`: the parent's code), because QuickBooks gives a + /// subaccount its parent's type and the two differ (INVARIANT 2). + AtTopLevel { + /// The account's code in the books. + code: String, + /// Its QuickBooks type. + own: QbAccountType, + /// Its parent's. + parent: QbAccountType, + }, + /// An entry's memo: its description in the books. + Memo { + /// The entry. + entry: EntryId, + }, +} + +/// Why an account number is left out of the file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LeftOut { + /// It is longer than QuickBooks Desktop holds ([`NUMBER_MAX`]). + TooLong, + /// Another account's number would be written the same (ignoring + /// case): this, as written. + Shared(String), +} + +/// One line, both texts quoted with anything unprintable escaped (`\t`, +/// `\n`, `\u{200b}`), so a change never breaks the list it is printed in, +/// and a full TIN in either masked, in any script's digits: +/// `account 6010's name "Café meals" is written "Cafe meals"`. +impl fmt::Display for IifChange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (books, written) = (shown(&self.books), shown(&self.written)); + let line = match &self.field { + IifField::AccountName { code } => format!( + "account {}'s name {books:?} is written {written:?}", + shown(code).escape_debug(), + ), + IifField::AccountNumber => { + format!("account number {books:?} is written {written:?}") + } + IifField::AccountNumberLeftOut { + why: LeftOut::TooLong, + } => format!( + "account number {books:?} is left out: QuickBooks Desktop holds {NUMBER_MAX} characters at most" + ), + IifField::AccountNumberLeftOut { + why: LeftOut::Shared(as_written), + } => format!( + "account number {books:?} is left out: another account's number would also be written {:?}", + shown(as_written) + ), + IifField::AtTopLevel { code, own, parent } => format!( + "account {}'s name is written at the top level, not under account {}'s: QuickBooks gives a subaccount its parent's type, and it is {}, its parent {}", + shown(code).escape_debug(), + books.escape_debug(), + own.keyword(), + parent.keyword() + ), + IifField::Memo { entry } => { + format!("entry {entry}'s memo {books:?} is written {written:?}") + } + }; + f.write_str(&redact_full_tins(&line)) + } +} + +/// `text` as a warning may show it (rules.md B-26: no full TIN): each run +/// of nine digits `ledger_core::parties::has_full_tin` finds masked (in +/// any script's digits and separators: "123-45-6789"), and if the +/// text still holds one once written as a field, every numeral in it +/// (digits a tab apart, which the file writes a space apart, or letters +/// deunicode writes as digits: "Ƽ" is "5"). +fn shown(text: &str) -> String { + let r = redact_full_tins(text).into_owned(); + if has_full_tin(&field(&r)) { + masked(&r) + } else { + r + } +} + +/// `text` with every numeral masked. +fn masked(text: &str) -> String { + text.chars() + .map(|c| if c.is_numeric() { '\u{2022}' } else { c }) + .collect() +} + +impl Iif { + /// What the file writes differently from the books, to tell the + /// human (INVARIANT 7), one line each: each account name and number + /// as [`IifChange`] prints it, then each thing written differently in + /// the memos once, with the entries it is in, in the order first met + /// (`"—" is written "--" in the memos of entries 3, 5, 9`), then the + /// memos cut to [`MEMO_MAX`]. A memo's run of changed characters is + /// one thing: "北京" is written "Bei Jing". Every change is in one + /// line or more, and no line holds a full TIN: a memo that holds one, + /// as the books keep it or once written, has every numeral of its runs + /// masked, and a line with digits masked says so (`"•••" is written + /// "•••" in the memo of entry 3 (digits masked: may be a TIN)`: + /// Arabic-Indic digits written in ASCII). + pub fn warnings(&self) -> Vec { + let mut lines = Vec::new(); + type Key = (String, String, bool); + let mut runs: Vec<(Key, Vec)> = Vec::new(); + let mut at: HashMap = HashMap::new(); + let mut cut: Vec = Vec::new(); + for c in &self.changes { + let IifField::Memo { entry } = c.field else { + lines.push(c.to_string()); + continue; + }; + let tin = has_full_tin(&c.books) || has_full_tin(&field(&c.books)); + for (from, to) in changed_runs(&c.books, MEMO_MAX) { + let key = match (masked(&from), masked(&to)) { + (f, t) if tin && (f != from || t != to) => (f, t, true), + _ => (from, to, false), + }; + let i = *at.entry(key.clone()).or_insert_with(|| { + runs.push((key, Vec::new())); + runs.len() - 1 + }); + runs[i].1.push(entry); + } + if field(&c.books).len() > MEMO_MAX { + cut.push(entry); + } + } + for ((from, to, tin), entries) in runs { + lines.push(format!( + "{from:?} is written {to:?} in {}{}", + memos_of(entries), + if tin { + " (digits masked: may be a TIN)" + } else { + "" + } + )); + } + if !cut.is_empty() { + let n = cut.len(); + lines.push(format!( + "{} {} cut to {MEMO_MAX} characters, the most QuickBooks Desktop holds", + memos_of(cut), + if n == 1 { "is" } else { "are" } + )); + } + lines + .into_iter() + .map(|l| redact_full_tins(&l).into_owned()) + .collect() + } +} + +/// "the memo of entry 3" or "the memos of entries 3, 5, 9", the ids in +/// order, each once. +fn memos_of(mut entries: Vec) -> String { + entries.sort_unstable(); + entries.dedup(); + let ids: Vec = entries.iter().map(ToString::to_string).collect(); + match ids.as_slice() { + [one] => format!("the memo of entry {one}"), + _ => format!("the memos of entries {}", ids.join(", ")), + } } /// Errors. @@ -127,93 +411,347 @@ pub enum IifError { /// The account. account: AccountId, }, + /// Two or more accounts would have one name in QuickBooks + /// (INVARIANT 6). + #[error( + "accounts {} would have the same name in QuickBooks, {name:?}: rename one", + .codes.join(", ") + )] + NameCollision { + /// The name, as the file would write it. + name: String, + /// The accounts' codes in the books. + codes: Vec, + }, + /// An account's NAME has a level longer than QuickBooks Desktop holds + /// (INVARIANT 2; [`iif_accounts`] never builds one). + #[error( + "account {code}'s name in QuickBooks, {name:?}, has a level longer than {NAME_MAX} characters" + )] + NameTooLong { + /// The account's code in the books. + code: String, + /// The NAME, as the file would write it. + name: String, + }, } /// Every account in the chart as QuickBooks will know it: its colon path -/// (parents first) and number, with `qb_type` deciding its QuickBooks type. +/// (parents first), number and QuickBooks type, `qb_type` deciding each +/// account's (INVARIANTS 1, 2, 4, 6), in the order of the books' codes, +/// each after its parent. +/// +/// A subaccount whose type differs from its parent's is written at the +/// top level, apart from it (`apart_from`), its own subaccounts with it: +/// QuickBooks gives a subaccount its parent's type, and the type decides +/// where its balance shows. Each level is the account's own name made safe +/// for IIF, any colon in it a '-', trimmed ("?" if nothing is left) and +/// cut to [`NAME_MAX`]. Level by level from the top, the accounts whose +/// paths would match ignoring ASCII case have " (code)" added to their own +/// names (cut to make room), the code made safe the same way ("no number" +/// for a blank one), until no two match: two "Software" accounts are +/// "Software (6110)" and "Software (6120)", their subaccounts following +/// them, and a third already named "Software (6110)" is then "Software +/// (6110) (6130)". A match that remains (two accounts whose codes are +/// written alike ignoring case, or alike in their first 30 characters) is +/// left for [`journal_to_iif`] to refuse. pub fn iif_accounts( ledger: &Ledger, qb_type: impl Fn(&ledger_core::Account) -> QbAccountType, ) -> Vec { - let path = |mut id: AccountId| { - let mut names = Vec::new(); - // A chart's parents form a tree; the bound guards a malformed one. - for _ in 0..64 { - let Some(a) = ledger.chart().get(id) else { + let accounts: Vec<&ledger_core::Account> = ledger.chart().all().collect(); + let types: HashMap = + accounts.iter().map(|a| (a.id, qb_type(a))).collect(); + // Its parent in QuickBooks: its parent in the books, of its type. + let parent_of = |a: &ledger_core::Account| { + a.parent + .filter(|p| types.get(p) == types.get(&a.id)) + .and_then(|p| ledger.chart().get(p)) + }; + // Each account's chain of ids, highest first. A chart's parents form a + // tree, so no chain is longer than the chart; the bound guards a + // malformed one. + let chain = |a: &ledger_core::Account| { + let mut ids = Vec::new(); + let mut at = Some(a); + for _ in 0..=accounts.len() { + let Some(a) = at else { break; }; - names.push(field(&a.name).replace(':', "-")); - match a.parent { - Some(p) => id = p, - None => break, + ids.push(a.id); + at = parent_of(a); + } + ids.reverse(); + ids + }; + let chains: HashMap> = + accounts.iter().map(|a| (a.id, chain(a))).collect(); + let own: HashMap = + accounts.iter().map(|a| (a.id, segment(&a.name))).collect(); + let tags: HashMap = accounts.iter().map(|a| (a.id, tag(&a.code))).collect(); + let mut level = own.clone(); + let path_of = |id: AccountId, level: &HashMap| { + chains[&id] + .iter() + .map(|p| level.get(p).map_or("?", String::as_str)) + .collect::>() + .join(":") + }; + // Level by level: a level's paths rest only on the levels above it, + // already settled. Within a level, each round tags every untagged + // account of each match, until a round tags none: each account is + // tagged once at most, so it ends. + let deepest = chains.values().map(Vec::len).max().unwrap_or(0); + let mut tagged: HashSet = HashSet::new(); + for depth in 1..=deepest { + loop { + let mut by_name: BTreeMap> = BTreeMap::new(); + for a in accounts.iter().filter(|a| chains[&a.id].len() == depth) { + by_name + .entry(path_of(a.id, &level).to_ascii_lowercase()) + .or_default() + .push(a.id); + } + let mut more = false; + for id in by_name.into_values().filter(|ids| ids.len() > 1).flatten() { + if tagged.insert(id) { + level.insert(id, with_tag(&own[&id], &tags[&id])); + more = true; + } + } + if !more { + break; } } - names.reverse(); - names.join(":") - }; - let mut out: Vec = ledger - .chart() - .all() + } + let mut out: Vec = accounts + .iter() .map(|a| IifAccount { id: a.id, - path: path(a.id), - number: field(&a.code), - qb_type: qb_type(a), + path: path_of(a.id, &level), + number: field(&a.code).trim().to_string(), + qb_type: types[&a.id], + name: a.name.clone(), + code: a.code.clone(), + apart_from: a + .parent + .and_then(|p| ledger.chart().get(p)) + .filter(|p| parent_of(a).is_none() && types.contains_key(&p.id)) + .map(|p| (p.code.clone(), types[&p.id])), }) .collect(); - out.sort_by(|a, b| a.number.cmp(&b.number)); - out + out.sort_by(|a, b| a.code.cmp(&b.code)); + let parents: HashMap = chains + .iter() + .filter_map(|(id, c)| Some((*id, *c.iter().rev().nth(1)?))) + .collect(); + parents_first(out, &parents) +} + +/// `accounts`, in the order given but each after its parent (INVARIANT 1: +/// a subaccount's NAME names its parent, which QuickBooks must know +/// first): the first account whose parent is already placed (or that has +/// none) goes next. An account caught in a cycle (a malformed chart) goes +/// last, in the order given. +fn parents_first( + accounts: Vec, + parents: &HashMap, +) -> Vec { + let at: HashMap = accounts + .iter() + .enumerate() + .map(|(i, a)| (a.id, i)) + .collect(); + let mut kids: HashMap> = HashMap::new(); + let mut ready: BTreeSet = BTreeSet::new(); + for (i, a) in accounts.iter().enumerate() { + match parents.get(&a.id).and_then(|p| at.get(p)) { + Some(&p) if p != i => kids.entry(p).or_default().push(i), + _ => { + ready.insert(i); + } + } + } + let mut order = Vec::with_capacity(accounts.len()); + let mut placed = vec![false; accounts.len()]; + while let Some(i) = ready.pop_first() { + order.push(i); + placed[i] = true; + ready.extend(kids.remove(&i).unwrap_or_default()); + } + order.extend((0..accounts.len()).filter(|&i| !placed[i])); + let mut slots: Vec> = accounts.into_iter().map(Some).collect(); + order.into_iter().filter_map(|i| slots[i].take()).collect() } /// Render posted entries as IIF: the accounts declared first, then one -/// transaction per entry. `DOCNUM` numbers entries from 1 in the order -/// given. -pub fn journal_to_iif( - accounts: &[IifAccount], - entries: &[&JournalEntry], -) -> Result { - let by_id: HashMap = accounts.iter().map(|a| (a.id, a)).collect(); - let mut out = String::new(); - out.push_str("!ACCNT\tNAME\tACCNTTYPE\tACCNUM\n"); - for a in accounts { - out.push_str(&format!( - "ACCNT\t{}\t{}\t{}\n", - a.path, - a.qb_type.keyword(), - a.number - )); +/// transaction per entry in the order given, each numbered (DOCNUM) with +/// its entry id; every text the file writes differently from the books, +/// and the entries with a line on accounts receivable or payable +/// (INVARIANTS 1-8). Refused when an entry uses an account `accounts` +/// lacks, two accounts would have one name, or a name has a level too long +/// for QuickBooks. +pub fn journal_to_iif(accounts: &[IifAccount], entries: &[&JournalEntry]) -> Result { + // Made safe again: it changes nothing iif_accounts built, and an + // account built some other way can't break the file. + let paths: Vec = accounts.iter().map(|a| field(&a.path)).collect(); + if let Some((a, path)) = accounts + .iter() + .zip(&paths) + .find(|(_, p)| p.split(':').any(|l| l.len() > NAME_MAX)) + { + return Err(IifError::NameTooLong { + code: a.code.clone(), + name: path.clone(), + }); } - out.push_str("!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\n"); - out.push_str("!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\n"); - out.push_str("!ENDTRNS\n"); - for (idx, entry) in entries.iter().enumerate() { - let docnum = idx + 1; + let mut by_name: BTreeMap> = BTreeMap::new(); + for (a, path) in accounts.iter().zip(&paths) { + by_name + .entry(path.to_ascii_lowercase()) + .or_default() + .push(&a.code); + } + if let Some((lower, codes)) = by_name.iter().find(|(_, c)| c.len() > 1) { + let name = paths + .iter() + .find(|p| p.to_ascii_lowercase() == *lower) + .cloned() + .unwrap_or_default(); + return Err(IifError::NameCollision { + name, + codes: codes.iter().map(|c| c.to_string()).collect(), + }); + } + // INVARIANT 6's numbers: made safe and trimmed; one too long, or + // written like another's ignoring case, is left out. + let numbers: Vec = accounts + .iter() + .map(|a| field(&a.number).trim().to_string()) + .collect(); + let mut uses: HashMap = HashMap::new(); + for n in numbers + .iter() + .filter(|n| !n.is_empty() && n.len() <= NUMBER_MAX) + { + *uses.entry(n.to_ascii_lowercase()).or_default() += 1; + } + let by_id: HashMap = accounts + .iter() + .zip(&paths) + .map(|(a, path)| (a.id, (path.as_str(), a.qb_type))) + .collect(); + let mut changes = Vec::new(); + let mut out = format!("!ACCNT\tNAME\tACCNTTYPE\tACCNUM{EOL}"); + for ((a, path), number) in accounts.iter().zip(&paths).zip(&numbers) { + let left_out = if number.len() > NUMBER_MAX { + Some(LeftOut::TooLong) + } else if uses + .get(&number.to_ascii_lowercase()) + .is_some_and(|&n| n > 1) + { + Some(LeftOut::Shared(number.clone())) + } else { + None + }; + let accnum = if left_out.is_some() { "" } else { number }; + out.push_str(&format!( + "ACCNT\t{path}\t{}\t{accnum}{EOL}", + a.qb_type.keyword() + )); + // The account's own level: what follows its parents' path. + let own = path.rsplit(':').next().unwrap_or_default(); + if own != a.name { + changes.push(IifChange { + field: IifField::AccountName { + code: a.code.clone(), + }, + books: a.name.clone(), + written: own.to_string(), + }); + } + if let Some((parent, parent_type)) = &a.apart_from { + changes.push(IifChange { + field: IifField::AtTopLevel { + code: a.code.clone(), + own: a.qb_type, + parent: *parent_type, + }, + books: parent.clone(), + written: String::new(), + }); + } + if let Some(why) = left_out { + changes.push(IifChange { + field: IifField::AccountNumberLeftOut { why }, + books: a.code.clone(), + written: String::new(), + }); + } else if accnum != a.code { + changes.push(IifChange { + field: IifField::AccountNumber, + books: a.code.clone(), + written: accnum.to_string(), + }); + } + } + out.push_str(&format!( + "!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO{EOL}" + )); + out.push_str(&format!( + "!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO{EOL}" + )); + out.push_str(&format!("!ENDTRNS{EOL}")); + let mut receivable_payable = Vec::new(); + for entry in entries { + let docnum = entry.id(); let d = entry.date(); let date = format!("{}/{}/{}", d.month(), d.day(), d.year()); - let memo = field(entry.description()); + let mut memo = field(entry.description()); + // ASCII, so every byte is a character. + memo.truncate(MEMO_MAX); + if memo != entry.description() { + changes.push(IifChange { + field: IifField::Memo { entry: docnum }, + books: entry.description().to_string(), + written: memo.clone(), + }); + } // TRNS carries the first debit; the rest are SPLs. let mut lines: Vec<_> = entry.lines().iter().collect(); lines.sort_by_key(|l| match l.side { Side::Debit => 0, Side::Credit => 1, }); + let mut named = false; for (n, line) in lines.iter().enumerate() { - let account = by_id - .get(&line.account) - .ok_or(IifError::UndeclaredAccount { - entry: entry.id(), - account: line.account, - })?; + let (account, qb_type) = + by_id + .get(&line.account) + .ok_or(IifError::UndeclaredAccount { + entry: entry.id(), + account: line.account, + })?; + named |= matches!( + qb_type, + QbAccountType::AccountsReceivable | QbAccountType::AccountsPayable + ); let keyword = if n == 0 { "TRNS" } else { "SPL" }; out.push_str(&format!( - "{keyword}\t\tGENERAL JOURNAL\t{date}\t{}\t\t{}\t{docnum}\t{memo}\n", - account.path, + "{keyword}\t\tGENERAL JOURNAL\t{date}\t{account}\t\t{}\t{docnum}\t{memo}{EOL}", format_amount(signed(line.side, line.amount.amount)), )); } - out.push_str("ENDTRNS\n"); + out.push_str(&format!("ENDTRNS{EOL}")); + if named { + receivable_payable.push(docnum); + } } - Ok(out) + Ok(Iif { + text: out, + changes, + receivable_payable, + }) } /// Debit positive, credit negative (Intuit: "Debit amounts are always @@ -230,20 +768,124 @@ fn format_amount(d: Decimal) -> String { format!("{d:.2}") } -/// A field IIF can carry: no tab or line break (there is no escape), and -/// ASCII only, common punctuation transliterated. -fn field(s: &str) -> String { - s.chars() - .map(|c| match c { - '\t' | '\n' | '\r' => ' ', - '\u{2013}' | '\u{2014}' | '\u{2212}' => '-', - '\u{2018}' | '\u{2019}' => '\'', - '\u{201C}' | '\u{201D}' => '"', - '\u{00A0}' => ' ', - c if c.is_ascii() && !c.is_ascii_control() => c, - _ => '?', +/// Whether `c` is a tab or ends a line (Unicode Standard Annex #14's +/// mandatory breaks: LF, VT, FF, CR, NEL, LINE SEPARATOR, PARAGRAPH +/// SEPARATOR): either would end an IIF field or record. +fn breaks(c: char) -> bool { + matches!( + c, + '\t' | '\n' | '\u{B}' | '\u{C}' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}' + ) +} + +/// Call `each` with every character of `s` and what a field writes for it +/// (INVARIANT 4), in order: printable ASCII is itself but for a double +/// quote, which is a single one; a tab or a line break is a space, any +/// other control character '?', and a character past ASCII its deunicode +/// transliteration ("é" is "e", "—" "--", "北" "Bei ", whose trailing +/// space deunicode drops at the end or before a space), or '?' when it +/// has none. +fn each_piece(s: &str, mut each: impl FnMut(char, &str)) { + let flat: String = s + .chars() + .map(|c| { + if breaks(c) { + ' ' + } else if c.is_control() { + '?' + } else { + c + } }) - .collect() + .collect(); + let mut piece = String::new(); + for (c, t) in s.chars().zip(flat.ascii_chars()) { + piece.clear(); + match t { + // deunicode writes ASCII, a few transliterations holding a line + // break; nothing else it writes is a control. + Some(t) => piece.extend(t.chars().map(|c| match c { + '\n' => ' ', + '"' => '\'', + c if c.is_ascii_control() || !c.is_ascii() => '?', + c => c, + })), + None => piece.push('?'), + } + each(c, &piece); + } +} + +/// A field IIF can carry (INVARIANT 4): printable ASCII with no double +/// quote, each character of `s` written as [`each_piece`] says. +fn field(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + each_piece(s, |_, piece| out.push_str(piece)); + out +} + +/// Each run of consecutive characters of `s` that [`field`] writes +/// differently, and what it writes for them, within the field's first +/// `limit` characters (a memo cut there keeps no more): "Café — 北京" +/// gives ("é", "e"), ("—", "--") and ("北京", "Bei Jing"). +fn changed_runs(s: &str, limit: usize) -> Vec<(String, String)> { + let mut runs = Vec::new(); + let mut run: Option<(String, String)> = None; + let mut len = 0; + each_piece(s, |c, piece| { + if len >= limit { + return; + } + // ASCII, so every byte is a character. + let piece = &piece[..piece.len().min(limit - len)]; + len += piece.len(); + let mut buf = [0u8; 4]; + if piece == c.encode_utf8(&mut buf) { + runs.extend(run.take()); + } else { + let (from, to) = run.get_or_insert_with(Default::default); + from.push(c); + to.push_str(piece); + } + }); + runs.extend(run); + runs +} + +/// `s`'s first `max` characters, any space they end with dropped. `s` is a +/// field (ASCII), so every byte is a character. +fn cut(s: &str, max: usize) -> &str { + s.get(..max).unwrap_or(s).trim_end() +} + +/// One account's own level in its path (INVARIANT 2): the name made safe, +/// any colon a '-' (a fullwidth colon transliterates to one), trimmed, +/// "?" if nothing is left, and cut to [`NAME_MAX`]. +fn segment(name: &str) -> String { + let s = field(name).replace(':', "-"); + match cut(s.trim(), NAME_MAX) { + "" => "?".to_string(), + t => t.to_string(), + } +} + +/// What tells an account's level from another's alike (INVARIANT 6): its +/// code, made safe like a level, or "no number" for a blank one. +fn tag(code: &str) -> String { + match field(code).replace(':', "-").trim() { + "" => "no number".to_string(), + t => t.to_string(), + } +} + +/// `own` followed by " (tag)", `own` cut to leave it room within +/// [`NAME_MAX`]; a tag with no room left beside it stands alone, cut. +fn with_tag(own: &str, tag: &str) -> String { + let suffix = format!(" ({tag})"); + match cut(own, NAME_MAX.saturating_sub(suffix.len())) { + "" => cut(suffix.trim_start(), NAME_MAX).to_string(), + base => format!("{base}{suffix}"), + } } #[cfg(test)] @@ -269,18 +911,183 @@ mod tests { AccountType::Asset => QbAccountType::Bank, _ => QbAccountType::Income, }); - let text = journal_to_iif(&accounts, &[ledger.get_entry(id).unwrap()]).unwrap(); + let iif = journal_to_iif(&accounts, &[ledger.get_entry(id).unwrap()]).unwrap(); assert_eq!( - text, - "!ACCNT\tNAME\tACCNTTYPE\tACCNUM\n\ - ACCNT\tCash\tBANK\t1000\n\ - ACCNT\tRevenue\tINC\t4000\n\ - !TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\n\ - !SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\n\ - !ENDTRNS\n\ - TRNS\t\tGENERAL JOURNAL\t1/5/2026\tCash\t\t1000.00\t1\tSale\n\ - SPL\t\tGENERAL JOURNAL\t1/5/2026\tRevenue\t\t-1000.00\t1\tSale\n\ - ENDTRNS\n" + iif.text, + "!ACCNT\tNAME\tACCNTTYPE\tACCNUM\r\n\ + ACCNT\tCash\tBANK\t1000\r\n\ + ACCNT\tRevenue\tINC\t4000\r\n\ + !TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\r\n\ + !SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO\r\n\ + !ENDTRNS\r\n\ + TRNS\t\tGENERAL JOURNAL\t1/5/2026\tCash\t\t1000.00\t1\tSale\r\n\ + SPL\t\tGENERAL JOURNAL\t1/5/2026\tRevenue\t\t-1000.00\t1\tSale\r\n\ + ENDTRNS\r\n" + ); + assert!(iif.changes.is_empty()); + } + + /// INVARIANTS 2 and 4 over every character there is (all 1,112,064 + /// Unicode scalar values, alone, after a letter and before one): a + /// field is printable ASCII with no double quote, so never a tab or a + /// line break, and making it safe again changes nothing; it lists a + /// changed run exactly when it writes the text differently; a level is + /// also never empty, never holds a colon, has no space at either end, + /// and is at most NAME_MAX characters. + #[test] + fn every_character_is_written_as_printable_ascii() { + let printable = |s: &str| s.bytes().all(|b| (0x20..0x7F).contains(&b) && b != b'"'); + let mut n = 0u32; + for c in (0..=0x10FFFFu32).filter_map(char::from_u32) { + n += 1; + for s in [c.to_string(), format!("a{c}"), format!("{c}b")] { + let w = field(&s); + assert!(printable(&w), "{c:?} is written {w:?}"); + assert_eq!(field(&w), w, "{c:?}"); + assert_eq!(changed_runs(&s, MEMO_MAX).is_empty(), w == s, "{c:?}"); + let own = segment(&s); + assert!(printable(&own) && !own.contains(':'), "{c:?}: {own:?}"); + assert!(!own.is_empty() && own == own.trim(), "{c:?}: {own:?}"); + assert!(own.len() <= NAME_MAX, "{c:?}: {own:?}"); + assert_eq!(segment(&own), own, "{c:?}"); + } + } + assert_eq!(n, 1_112_064); + // Printable ASCII is itself, every character of it but the double + // quote. + let ascii: String = (0x20u8..0x7F).map(char::from).collect(); + assert_eq!(field(&ascii), ascii.replace('"', "'")); + assert_eq!( + changed_runs(&ascii, MEMO_MAX), + [("\"".to_string(), "'".to_string())] ); } + + /// deunicode 1.6.0's table, sampled (Cargo.toml pins it exactly): a + /// version that writes one of these differently fails here, never + /// quietly in a CPA's file. + #[test] + fn the_transliteration_table_is_pinned() { + for (from, to) in [ + ("René", "Rene"), + ("Café", "Cafe"), + ("北京", "Bei Jing"), + ("東京", "Dong Jing"), + ("서울", "seoul"), + ("ありがとう", "arigatou"), + ("Łódź", "Lodz"), + ("Straße", "Strasse"), + ("Æneid", "AEneid"), + ("€5", "EUR5"), + ("a\u{2013}b\u{2014}c\u{2212}d", "a-b--c-d"), + ("\u{201C}q\u{201D} \u{2018}s\u{2019}", "'q' 's'"), + ("say \"hi\"", "say 'hi'"), + ("\u{201E}q\u{AB}", ",,q<<"), + ("x\u{A0}y", "x y"), + ("\u{2192}", "-"), + ("Ratio\u{FF1A}Ops", "Ratio:Ops"), + ("\u{1F600}", "grinning"), + ("\u{2615} break", "coffee break"), + ("e\u{301}", "e"), + ("\u{FEFF}x\u{200B}y", "x y"), + ("\u{E000}", "?"), + ] { + assert_eq!(field(from), to, "{from:?}"); + } + } + + /// INVARIANT 4's controls: a tab, LF, CR, VT, FF, NEL and the two + /// Unicode separators are spaces; any other control is '?'. + #[test] + fn breaks_are_spaces_and_other_controls_question_marks() { + assert_eq!( + field("a\tb\nc\r\nd\u{B}e\u{C}f\u{85}g\u{2028}h\u{2029}i"), + "a b c d e f g h i" + ); + assert_eq!(field("a\u{0}b\u{1}c\u{1B}d\u{7F}e\u{9F}f"), "a?b?c?d?e?f"); + } + + /// INVARIANT 2: a level is trimmed, never empty, never a colon, and + /// cut to NAME_MAX characters, any space it then ends with dropped. + #[test] + fn a_level_is_trimmed_never_empty_and_cut_to_31() { + assert_eq!(segment(" Rent \t"), "Rent"); + assert_eq!(segment("北京"), "Bei Jing"); + assert_eq!(segment("\u{FEFF}"), "?"); + assert_eq!(segment("a:b\u{FF1A}c"), "a-b-c"); + assert_eq!(NAME_MAX, 31); + let long = "Professional services and consulting fees"; + assert_eq!(segment(long), "Professional services and consu"); + assert_eq!(segment(long).len(), 31); + // Cut just after a space: the space goes. + assert_eq!( + segment("abcdefghij abcdefghij abcdefgh xyz"), + "abcdefghij abcdefghij abcdefgh" + ); + assert_eq!(segment(&"x".repeat(31)), "x".repeat(31)); + // Transliterated first, then cut. + assert_eq!( + segment("北京北京北京北京北京"), + "Bei Jing Bei Jing Bei Jing Bei" + ); + } + + /// INVARIANT 6's tag: " (code)" fits in NAME_MAX, the name cut to make + /// room; a code with a colon can't add a level; a blank code is "no + /// number"; a tag too long to fit beside the name stands alone, cut. + #[test] + fn a_tag_fits_and_never_adds_a_level() { + assert_eq!(with_tag("Software", &tag("6100")), "Software (6100)"); + assert_eq!( + with_tag( + &segment("Professional services and consulting fees"), + &tag("6100") + ), + "Professional services an (6100)" + ); + assert_eq!(with_tag("Software", &tag("61:00")), "Software (61-00)"); + assert_eq!(with_tag("Software", &tag(" ")), "Software (no number)"); + assert_eq!( + with_tag("Software", &tag(" 6\u{FF11}00 ")), + "Software (6100)" + ); + let t = tag(&"9".repeat(40)); + assert_eq!(with_tag("Software", &t), format!("({}", "9".repeat(30))); + let (long, code) = ("x".repeat(31), "7".repeat(28)); + for (own, code) in [ + ("x", "1"), + (long.as_str(), "1234567"), + ("ab", code.as_str()), + ] { + let w = with_tag(own, &tag(code)); + assert!( + w.len() <= NAME_MAX && !w.contains(':') && w == w.trim(), + "{w:?}" + ); + } + } + + /// Iif::warnings' runs: each run of changed characters once, with what + /// it is written as, within the first `limit` characters written. + #[test] + fn changed_runs_are_the_characters_written_differently() { + let runs = |s: &str, limit: usize| changed_runs(s, limit); + let pairs = |v: &[(&str, &str)]| -> Vec<(String, String)> { + v.iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect() + }; + assert_eq!( + runs("Café — 北京", MEMO_MAX), + pairs(&[("é", "e"), ("—", "--"), ("北京", "Bei Jing")]) + ); + assert_eq!( + runs("a\t☕\nb \"c\"", MEMO_MAX), + pairs(&[("\t☕\n", " coffee "), ("\"", "'"), ("\"", "'")]) + ); + assert_eq!(runs("plain", MEMO_MAX), pairs(&[])); + // Within the limit: "北" is written "Bei ", cut to "Be". + assert_eq!(runs("ab北京", 4), pairs(&[("北", "Be")])); + assert_eq!(runs("abc—", 3), pairs(&[])); + } } diff --git a/crates/ledger-import/src/labels.rs b/crates/ledger-import/src/labels.rs index b30e54d..1057995 100644 --- a/crates/ledger-import/src/labels.rs +++ b/crates/ledger-import/src/labels.rs @@ -3,7 +3,7 @@ //! Each label carries the company name, the asset tag (its headline), what //! the asset is, its serial number when recorded, and a QR code holding //! exactly the tag. Labels are typeset by `templates/labels.typ` with the -//! same bundled Inter fonts as [`crate::pdf`]; the labels reach Typst as +//! same bundled fonts as [`crate::pdf`]; the labels reach Typst as //! JSON data (`sys.inputs.data`), never as markup. The sheet geometry comes //! from the label maker's own specification, vendored in //! `docs/spec/sources/avery-5160.md`. @@ -38,7 +38,7 @@ //! - A partly used sheet with 7 labels left: skip 23. //! ``` -use crate::pdf::{INTER, PdfError}; +use crate::pdf::PdfError; use qrcode::render::svg; use qrcode::{EcLevel, QrCode}; use std::sync::OnceLock; @@ -145,7 +145,7 @@ fn engine() -> &'static TypstEngine { ENGINE.get_or_init(|| { TypstEngine::builder() .main_file(TEMPLATE) - .fonts(INTER) + .fonts(crate::fonts::typst_fonts()) .build() }) } diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index a6fc6c0..43f4d25 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -8,12 +8,14 @@ //! - [`pdf`]: generate PDF financial statements (CPA handoff). //! - [`asset_register`]: the fixed asset register as XLSX and PDF. //! - [`labels`]: print asset label sheets with QR codes. +//! - [`fonts`]: the bundled fonts the PDFs and the viewer share. //! //! All money stays `Decimal`; no floats in money paths. pub mod asset_register; pub mod bank_csv; pub mod chart_csv; +pub mod fonts; pub mod iif; pub mod labels; pub mod pdf; @@ -24,7 +26,10 @@ pub mod xlsx; pub use asset_register::{asset_register_pdf, asset_register_xlsx}; pub use bank_csv::{BankCsvError, BankTransaction, DateOrder, parse_bank_csv, parse_bank_csv_with}; pub use chart_csv::{ChartRow, parse_chart_csv}; -pub use iif::{IifAccount, IifError, QbAccountType, iif_accounts, journal_to_iif}; +pub use iif::{ + Iif, IifAccount, IifChange, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX, + QbAccountType, iif_accounts, journal_to_iif, +}; pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf}; pub use pdf::{PdfError, statement_pdf}; pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount}; diff --git a/crates/ledger-import/src/pdf.rs b/crates/ledger-import/src/pdf.rs index e78098e..4504a53 100644 --- a/crates/ledger-import/src/pdf.rs +++ b/crates/ledger-import/src/pdf.rs @@ -4,10 +4,14 @@ //! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and //! renders the PDF. Amounts arrive already split by //! [`crate::report::amount_parts`] — Typst never does arithmetic on money, -//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see -//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on -//! what's installed, and it is deterministic: the same statement and -//! report metadata render to the same bytes. +//! and labels are data, never markup. The fonts are bundled, so output +//! doesn't depend on what's installed, and it is deterministic: the same +//! statement and report metadata render to the same bytes. Inter sets +//! the text; the Noto fallbacks ([`crate::fonts`]) draw what Inter can't: +//! Chinese, Japanese, Korean, Arabic, Hebrew, Thai and Devanagari, so a +//! vendor or an account named in them prints as written, right-to-left +//! where it is written so, never as empty boxes. (Emoji have no font +//! here: an emoji prints as an empty box.) use crate::report::{ReportMeta, RowKind, Statement, amount_parts}; use std::sync::OnceLock; @@ -42,11 +46,6 @@ pub enum PdfError { } static TEMPLATE: &str = include_str!("../templates/statement.typ"); -pub(crate) static INTER: [&[u8]; 3] = [ - include_bytes!("../fonts/Inter-Regular.ttf"), - include_bytes!("../fonts/Inter-SemiBold.ttf"), - include_bytes!("../fonts/Inter-Bold.ttf"), -]; /// The engine, built once: parsing the bundled fonts is the expensive part. fn engine() -> &'static TypstEngine { @@ -54,7 +53,7 @@ fn engine() -> &'static TypstEngine { ENGINE.get_or_init(|| { TypstEngine::builder() .main_file(TEMPLATE) - .fonts(INTER) + .fonts(crate::fonts::typst_fonts()) .build() }) } @@ -124,6 +123,10 @@ fn typeset(st: &Statement, meta: &ReportMeta) -> Result /// ("#panic()", "*", "$", "[") prints as text and NEVER runs. /// 3. Every row reaches the page: template_data carries each row's label, /// kind, and amounts split by amount_parts, in order. +/// 4. Every character prints as itself when a bundled font has it: +/// Chinese, Japanese, Korean, Arabic, Hebrew, Thai and Devanagari +/// from the Noto fallbacks (crate::fonts), Arabic and Hebrew right to +/// left, never an empty box. /// ``` pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result, PdfError> { let doc = typeset(st, meta)?; @@ -221,4 +224,112 @@ mod tests { assert!(doc.pages().len() > 1, "200 rows should span pages"); assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF")); } + + /// Each text run on the pages: its text, its font's family, and its + /// glyphs' ids in that font (0 is the font's empty box, "notdef"). + fn runs(doc: &PagedDocument) -> Vec<(String, String, Vec)> { + use typst::layout::{Frame, FrameItem}; + fn walk(frame: &Frame, out: &mut Vec<(String, String, Vec)>) { + for (_, item) in frame.items() { + match item { + FrameItem::Group(g) => walk(&g.frame, out), + FrameItem::Text(t) => out.push(( + t.text.to_string(), + t.font.font().info().family.clone(), + t.glyphs.iter().map(|g| g.id).collect(), + )), + _ => {} + } + } + } + let mut out = Vec::new(); + for p in doc.pages() { + walk(&p.frame, &mut out); + } + out + } + + /// INVARIANT 4: a company, an account and a vendor named in Chinese, + /// Japanese and Korean print in the CJK fallback, every glyph a real + /// one; the rest stays Inter. (With Inter alone, every one of these + /// was glyph 0, an empty box.) + #[test] + fn chinese_japanese_and_korean_print_as_written() { + let mut m = meta(); + m.company = "北京 Trading 株式会社".to_string(); + let mut st = profit_and_loss(&pl()); + st.rows.push(Row { + kind: RowKind::Line, + label: "6100 東京 서울 ありがとう ABC".into(), + amounts: vec![Some(Decimal::ONE)], + currency: false, + rule_above: false, + }); + let runs = runs(&typeset(&st, &m).unwrap()); + let mut seen = String::new(); + for (text, family, glyphs) in &runs { + assert!( + !glyphs.contains(&0), + "{text:?} has an empty box in {family}" + ); + let cjk = text.chars().any(|c| { + matches!(u32::from(c), 0x1100..=0x11FF | 0x3000..=0x9FFF | 0xAC00..=0xD7AF | 0xFF00..=0xFFEF) + }); + let ascii = text.is_ascii(); + if cjk { + assert_eq!(family, "Noto Sans CJK SC", "{text:?}"); + seen.push_str(text); + } else if ascii { + assert_eq!(family, "Inter", "{text:?}"); + } + } + for want in ["北京", "株式会社", "東京", "서울", "ありがとう", "ABC"] { + assert!(seen.contains(want), "{want} printed: {runs:?}"); + } + } + + /// INVARIANT 4 for the other bundled scripts: Arabic, Hebrew, Thai and + /// Devanagari each print in their own Noto fallback, every glyph a + /// real one (the right-to-left reading is checked on the PDF itself, + /// `tests/spec_import.rs` `pdf_prints_every_bundled_script_as_written`). + #[test] + fn arabic_hebrew_thai_and_devanagari_print_as_written() { + let mut m = meta(); + m.company = "شركة Example".to_string(); + let mut st = profit_and_loss(&pl()); + for label in ["مكتب", "שלום", "สวัสดี", "नमस्ते"] { + st.rows.push(Row { + kind: RowKind::Line, + label: label.into(), + amounts: vec![Some(Decimal::ONE)], + currency: false, + rule_above: false, + }); + } + let runs = runs(&typeset(&st, &m).unwrap()); + let family_of = |c: char| match u32::from(c) { + 0x0600..=0x06FF => Some("Noto Sans Arabic UI"), + 0x0590..=0x05FF => Some("Noto Sans Hebrew"), + 0x0E00..=0x0E7F => Some("Noto Sans Thai"), + 0x0900..=0x097F => Some("Noto Sans Devanagari"), + _ => None, + }; + for (text, family, glyphs) in &runs { + assert!( + !glyphs.contains(&0), + "{text:?} has an empty box in {family}" + ); + if let Some(want) = text.chars().find_map(family_of) { + assert_eq!(family, want, "{text:?}"); + } + } + // Every letter printed in its script's font. + for c in "شركةمكتبשלוםสวัสดีनमस्ते".chars() { + let want = family_of(c).unwrap(); + assert!( + runs.iter().any(|(t, f, _)| f == want && t.contains(c)), + "{c} printed in {want}: {runs:?}" + ); + } + } } diff --git a/crates/ledger-import/templates/asset_register.typ b/crates/ledger-import/templates/asset_register.typ index d150e80..499028a 100644 --- a/crates/ledger-import/templates/asset_register.typ +++ b/crates/ledger-import/templates/asset_register.typ @@ -26,7 +26,7 @@ #let firm = 0.6pt + ink #set document(title: d.company + " — " + d.title, author: d.company) -#set text(font: "Inter", size: 8pt, fill: ink, number-width: "tabular", hyphenate: false) +#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 8pt, fill: ink, number-width: "tabular", hyphenate: false) #set page( paper: "us-letter", flipped: true, diff --git a/crates/ledger-import/templates/labels.typ b/crates/ledger-import/templates/labels.typ index ac8e560..f399ade 100644 --- a/crates/ledger-import/templates/labels.typ +++ b/crates/ledger-import/templates/labels.typ @@ -20,7 +20,7 @@ #let quiet = luma(90) #set document(title: d.company + " — Asset labels", author: d.company) -#set text(font: "Inter", fill: ink, number-width: "tabular", hyphenate: false) +#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), fill: ink, number-width: "tabular", hyphenate: false) #set par(leading: 0.28em, spacing: 0pt) #set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt) diff --git a/crates/ledger-import/templates/statement.typ b/crates/ledger-import/templates/statement.typ index 76d22aa..e97a167 100644 --- a/crates/ledger-import/templates/statement.typ +++ b/crates/ledger-import/templates/statement.typ @@ -28,7 +28,7 @@ #let firm = 0.6pt + ink #set document(title: d.company + " — " + d.title, author: d.company) -#set text(font: "Inter", size: if dense { 7pt } else { 9pt }, fill: ink, number-width: "tabular") +#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: if dense { 7pt } else { 9pt }, fill: ink, number-width: "tabular") #set page( paper: "us-letter", flipped: wide, diff --git a/crates/ledger-import/tests/spec_import.rs b/crates/ledger-import/tests/spec_import.rs index 0c804e7..9b4ee9c 100644 --- a/crates/ledger-import/tests/spec_import.rs +++ b/crates/ledger-import/tests/spec_import.rs @@ -21,9 +21,10 @@ use ledger_core::{ use ledger_import::chart_csv::ChartCsvError; use ledger_import::report::{self, Member, ReportMeta, Row, RowKind, Statement, YearEarnings}; use ledger_import::{ - BankCsvError, BankTransaction, DateOrder, EntityType, IifAccount, IifError, QbAccountType, - XlsxReport, chart_template, format_amount, iif_accounts, journal_to_iif, parse_bank_csv, - parse_bank_csv_with, parse_chart_csv, report_to_xlsx, statement_pdf, + BankCsvError, BankTransaction, DateOrder, EntityType, Iif, IifAccount, IifChange, IifError, + IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX, QbAccountType, XlsxReport, chart_template, + format_amount, iif_accounts, journal_to_iif, parse_bank_csv, parse_bank_csv_with, + parse_chart_csv, report_to_xlsx, statement_pdf, }; use proptest::prelude::*; use rust_decimal::{Decimal, RoundingStrategy}; @@ -1069,18 +1070,19 @@ fn qb(a: &ledger_core::Account) -> QbAccountType { } } -/// Check an IIF document against iif.rs INVARIANTS 1-4 and the layout, +/// Check an IIF document against iif.rs INVARIANTS 1-6 and the layout, /// for `entries` written with `accounts`. fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) { - // INVARIANT 4: ASCII only. + // INVARIANT 4: ASCII text, every line ending CR LF, and no other CR or + // LF, tab or control character but the tabs between fields. assert!(text.is_ascii(), "non-ASCII in IIF"); - let lines: Vec<&str> = text - .split('\n') - .map(|l| l.strip_suffix('\r').unwrap_or(l)) - .filter(|l| !l.is_empty()) - .collect(); + assert!(text.ends_with("\r\n"), "the last line ends CR LF"); + let lines: Vec<&str> = text.strip_suffix("\r\n").unwrap().split("\r\n").collect(); for l in &lines { - assert!(!l.contains('\r'), "stray CR inside a line: {l:?}"); + assert!( + l.bytes().all(|b| b == b'\t' || (0x20..0x7F).contains(&b)), + "a control character (a bare CR or LF?) inside a line: {l:?}" + ); } let keywords = [ "!ACCNT", "ACCNT", "!TRNS", "!SPL", "!ENDTRNS", "TRNS", "SPL", "ENDTRNS", @@ -1116,9 +1118,16 @@ fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) { ); match kw { "ACCNT" => { - // INVARIANT 1: every account precedes the transactions. + // INVARIANT 1: every account precedes the transactions, + // and its parent precedes it. assert!(!seen_trns_header, "ACCNT after !TRNS"); let name = f[h.iter().position(|c| *c == "NAME").unwrap()]; + if let Some((parent, _)) = name.rsplit_once(':') { + assert!( + declared.contains(parent), + "{name:?} before its parent {parent:?}" + ); + } declared.insert(name.to_string()); } "TRNS" => { @@ -1136,6 +1145,9 @@ fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) { accounts.len(), "every account declared once" ); + // INVARIANT 6: no two NAMEs alike, even ignoring case. + let lower: HashSet = declared.iter().map(|n| n.to_ascii_lowercase()).collect(); + assert_eq!(lower.len(), declared.len(), "two NAMEs alike: {declared:?}"); let col = |kw: &str, name: &str| -> usize { headers[kw] .iter() @@ -1154,24 +1166,41 @@ fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) { .find(|f| f[col("ACCNT", "NAME")] == a.path) .unwrap_or_else(|| panic!("{} not declared", a.path)); assert_eq!(row[col("ACCNT", "ACCNTTYPE")], a.qb_type.keyword()); - assert_eq!(row[col("ACCNT", "ACCNUM")], a.number); + // INVARIANT 2: a subaccount has its parent's QuickBooks type. + if let Some((parent, _)) = a.path.rsplit_once(':') { + let p = accounts.iter().find(|p| p.path == parent).unwrap(); + assert_eq!(p.qb_type, a.qb_type, "{:?} under {parent:?}", a.path); + } + // Its number, or none where QuickBooks can't hold it (INVARIANT 6; + // check_changes says which). + let accnum = row[col("ACCNT", "ACCNUM")]; + assert!( + accnum == a.number || accnum.is_empty(), + "{accnum:?} for {:?}", + a.number + ); } let path_of: HashMap = accounts.iter().map(|a| (a.id, a.path.as_str())).collect(); assert_eq!(txns.len(), entries.len(), "one transaction per entry"); - for (i, (t, e)) in txns.iter().zip(entries).enumerate() { + for (t, e) in txns.iter().zip(entries) { let mut sum = Decimal::ZERO; let mut got: Vec<(String, Decimal)> = Vec::new(); for (j, f) in t.iter().enumerate() { let kw = if j == 0 { "TRNS" } else { "SPL" }; - // INVARIANT 3: GENERAL JOURNAL, dated M/D/YYYY. + // INVARIANT 3: GENERAL JOURNAL, dated M/D/YYYY, numbered with + // the entry's id on every line. assert_eq!(f[col(kw, "TRNSTYPE")], "GENERAL JOURNAL"); let dt = e.date(); assert_eq!( f[col(kw, "DATE")], format!("{}/{}/{}", dt.month(), dt.day(), dt.year()) ); - assert_eq!(f[col(kw, "DOCNUM")], (i + 1).to_string(), "DOCNUM from 1"); + assert_eq!( + f[col(kw, "DOCNUM")], + e.id().to_string(), + "DOCNUM is the entry id" + ); let acct = f[col(kw, "ACCNT")]; // INVARIANT 1/5: every account used is declared. assert!(declared.contains(acct), "{acct:?} used but not declared"); @@ -1181,12 +1210,7 @@ fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) { got.push((acct.to_string(), a)); } // INVARIANT 3: debits positive, credits negative, sum zero. - assert_eq!( - sum, - Decimal::ZERO, - "transaction {} doesn't sum to zero", - i + 1 - ); + assert_eq!(sum, Decimal::ZERO, "entry {} doesn't sum to zero", e.id()); let mut want: Vec<(String, Decimal)> = e .lines() .iter() @@ -1202,6 +1226,245 @@ fn check_iif(text: &str, accounts: &[IifAccount], entries: &[&JournalEntry]) { } } +/// Each transaction's MEMO as the file writes it, in file order. +fn iif_memos(text: &str) -> Vec { + let header: Vec<&str> = text + .split("\r\n") + .find(|l| l.starts_with("!TRNS\t")) + .unwrap() + .split('\t') + .collect(); + let at = header.iter().position(|c| *c == "MEMO").unwrap(); + text.split("\r\n") + .filter(|l| l.starts_with("TRNS\t")) + .map(|l| l.split('\t').nth(at).unwrap().to_string()) + .collect() +} + +/// Each account's ACCNUM as the file writes it, by NAME. +fn iif_numbers(text: &str) -> HashMap { + text.split("\r\n") + .filter(|l| l.starts_with("ACCNT\t")) + .map(|l| { + let f: Vec<&str> = l.split('\t').collect(); + (f[1].to_string(), f[3].to_string()) + }) + .collect() +} + +/// iif INVARIANTS 6 and 7 against the file: every ACCNUM is at most +/// NUMBER_MAX characters and no other's (ignoring case), and is the +/// account's number unless that is too long or shared, when it is blank; +/// `changes` lists, in file order, each account whose own level or number +/// the file writes differently from the books (a number left out with +/// why), then each entry whose memo it writes differently, with both +/// texts; and nothing else. Its warnings say every change, one line each. +fn check_changes(iif: &Iif, accounts: &[IifAccount], entries: &[&JournalEntry]) { + let written = iif_numbers(&iif.text); + let mut seen: HashSet = HashSet::new(); + for n in written.values().filter(|n| !n.is_empty()) { + assert!(n.len() <= NUMBER_MAX, "ACCNUM {n:?} too long"); + assert!(seen.insert(n.to_ascii_lowercase()), "ACCNUM {n:?} twice"); + } + let alike = |n: &str| { + accounts + .iter() + .filter(|a| a.number.eq_ignore_ascii_case(n)) + .count() + }; + let mut want: Vec = Vec::new(); + for a in accounts { + let own = a.path.rsplit(':').next().unwrap(); + if own != a.name { + want.push(IifChange { + field: IifField::AccountName { + code: a.code.clone(), + }, + books: a.name.clone(), + written: own.to_string(), + }); + } + if let Some((parent, parent_type)) = &a.apart_from { + want.push(IifChange { + field: IifField::AtTopLevel { + code: a.code.clone(), + own: a.qb_type, + parent: *parent_type, + }, + books: parent.clone(), + written: String::new(), + }); + } + let left_out = if a.number.len() > NUMBER_MAX { + Some(LeftOut::TooLong) + } else if !a.number.is_empty() && alike(&a.number) > 1 { + Some(LeftOut::Shared(a.number.clone())) + } else { + None + }; + let accnum = &written[&a.path]; + match left_out { + Some(why) => { + assert_eq!(accnum, "", "{:?} left out", a.number); + want.push(IifChange { + field: IifField::AccountNumberLeftOut { why }, + books: a.code.clone(), + written: String::new(), + }); + } + None => { + assert_eq!(accnum, &a.number); + if a.number != a.code { + want.push(IifChange { + field: IifField::AccountNumber, + books: a.code.clone(), + written: a.number.clone(), + }); + } + } + } + } + for (e, memo) in entries.iter().zip(iif_memos(&iif.text)) { + assert!(memo.len() <= MEMO_MAX, "entry {}'s memo too long", e.id()); + if memo != e.description() { + want.push(IifChange { + field: IifField::Memo { entry: e.id() }, + books: e.description().to_string(), + written: memo, + }); + } + } + assert_eq!(iif.changes, want); + check_warnings(iif); +} + +/// A string Rust's `{:?}` printed at the start of `s`, read back, and +/// what follows it. +fn read_quoted(s: &str) -> Option<(String, &str)> { + let mut chars = s.strip_prefix('"')?.char_indices(); + let mut out = String::new(); + while let Some((i, c)) = chars.next() { + match c { + '"' => return Some((out, &s[i + 2..])), + '\\' => match chars.next()?.1 { + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + '0' => out.push('\0'), + 'u' => { + let mut hex = String::new(); + chars.next()?; // '{' + for (_, h) in chars.by_ref() { + if h == '}' { + break; + } + hex.push(h); + } + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + other => out.push(other), + }, + c => out.push(c), + } + } + None +} + +/// iif INVARIANT 7's warnings: one line each, never a full TIN; each +/// account name or number change its own line, as the change prints; each +/// memo change named in one line at least, and every memo line true of +/// each entry it names: `"from" is written "to" in the memos of entries +/// ...` with `from` in the books' memo and `to` in the file's, or the line +/// of memos cut to MEMO_MAX naming exactly those cut. A line with a masked +/// TIN-like run is taken as it is. +fn check_warnings(iif: &Iif) { + let lines = iif.warnings(); + let memos: HashMap = iif + .changes + .iter() + .filter_map(|c| match c.field { + IifField::Memo { entry } => Some((entry, c)), + _ => None, + }) + .collect(); + let mut named: HashSet = HashSet::new(); + for l in &lines { + assert!(!l.chars().any(char::is_control), "one line: {l:?}"); + assert!(!ledger_core::parties::has_full_tin(l), "a TIN in {l:?}"); + let ids = l + .rsplit_once(" the memo of entry ") + .or_else(|| l.rsplit_once(" the memos of entries ")) + .or_else(|| l.split_once("the memo of entry ")) + .or_else(|| l.split_once("the memos of entries ")); + let Some((_, ids)) = ids else { + continue; + }; + let list: String = ids + .chars() + .take_while(|c| c.is_ascii_digit() || *c == ',' || *c == ' ') + .collect(); + let ids: Vec = list + .trim_end() + .split(", ") + .map(|t| t.parse().unwrap()) + .collect(); + assert!( + !ids.is_empty() && ids.windows(2).all(|w| w[0] < w[1]), + "{l:?}" + ); + named.extend(&ids); + if l.contains('\u{2022}') { + continue; + } + if l.starts_with("the memo") { + let cut: Vec = memos + .iter() + .filter(|(_, c)| c.written.len() == MEMO_MAX) + .map(|(e, _)| *e) + .collect(); + for e in &ids { + assert!(cut.contains(e), "entry {e} isn't cut: {l:?}"); + } + continue; + } + let (from, rest) = read_quoted(l).unwrap_or_else(|| panic!("{l:?}")); + let rest = rest.strip_prefix(" is written ").unwrap(); + let (to, rest) = read_quoted(rest).unwrap(); + assert!(rest.starts_with(" in the memo"), "{l:?}"); + assert!(!from.is_empty() && from != to, "{l:?}"); + for e in &ids { + let c = memos[e]; + assert!( + c.books.contains(&from), + "{from:?} not in entry {e}'s {:?}", + c.books + ); + assert!( + c.written.contains(&to), + "{to:?} not in entry {e}'s {:?}", + c.written + ); + } + } + for c in &iif.changes { + match c.field { + IifField::Memo { entry } => { + assert!( + named.contains(&entry), + "entry {entry}'s memo isn't in {lines:?}" + ) + } + _ => assert!(lines.contains(&c.to_string()), "{c} isn't in {lines:?}"), + } + } +} + +/// Whether `s` is text INVARIANT 4 writes as itself: printable ASCII +/// without a double quote. +fn plain(s: &str) -> bool { + s.bytes().all(|b| (0x20..0x7F).contains(&b) && b != b'"') +} + /// The colon path an account should have: its own name and its parents', /// highest first (each with any colon replaced, so the depth is exact). fn depth(ledger: &Ledger, mut id: AccountId) -> usize { @@ -1213,21 +1476,86 @@ fn depth(ledger: &Ledger, mut id: AccountId) -> usize { n } +/// Text for names and memos, mixing what INVARIANT 4 keeps (ASCII), what +/// it transliterates (Latin-1 and other letters, curly quotes, dashes, a +/// no-break space, a fullwidth colon, Chinese, Japanese, Korean, emoji, +/// combining marks, invisible format characters), what it writes '?' +/// (controls, private use) and what it writes as a space (tabs and every +/// kind of line break). +fn any_text() -> impl Strategy { + "[A-Za-z0-9 :()#$\t\n\r\u{B}\u{C}\u{85}\u{2028}\u{2029}é—–“”’\u{A0}Ł北京서울あ😀☕\u{301}\u{200B}\u{FEFF}\u{1}\u{7F}\u{9F}\u{E000}\u{FF1A}]{0,12}" +} + +/// A random chart for the name properties: account i under an earlier +/// one or at the top, its name from a pool that makes clashes (the same +/// name twice, differing only by case, a fullwidth colon against a +/// hyphen, two private-use names that are both '?', Chinese against its +/// transliteration, a name that is another's " (code)" form, names past +/// NAME_MAX that are alike once cut, and one that is another's cut form) +/// or from [`any_text`]. +fn chart_strategy() -> impl Strategy)>> { + let name = prop_oneof![ + proptest::sample::select(vec![ + "Software", + "software", + "Ratio\u{FF1A}Ops", + "Ratio-Ops", + "\u{E000}", + "\u{E001}", + "北京", + "Bei Jing", + "Software (6001)", + " Rent ", + "Professional services and consulting fees", + "Professional services and consulting costs", + "Professional services and consu", + "Professional services an (6002)", + ]) + .prop_map(String::from), + any_text(), + ]; + proptest::collection::vec((name, any::(), any::()), 1..12).prop_map(|v| { + v.into_iter() + .enumerate() + .map(|(i, (n, pick, top))| { + let parent = (i > 0 && !top).then(|| (pick % i as u64) as usize); + (n, parent) + }) + .collect() + }) +} + proptest! { #![proptest_config(ProptestConfig::with_cases(128))] - /// iif INVARIANTS 1-4 over random books: accounts declared before the - /// transactions; colon paths; TRNS/SPL/ENDTRNS, GENERAL JOURNAL, - /// M/D/YYYY, debits positive and credits negative summing to zero; - /// ASCII only, no tab or line break inside a field. + /// iif INVARIANTS 1-7 over random books with memos of every kind: + /// accounts declared before the transactions, colon paths; TRNS/SPL/ + /// ENDTRNS, GENERAL JOURNAL, M/D/YYYY, DOCNUM the entry id, debits + /// positive and credits negative summing to zero; ASCII lines ending CR + /// LF, no tab or line break inside a field; plain ASCII written as + /// itself; every change listed. #[test] - fn iif_round_trips_random_books((specs, oe, _) in books_strategy()) { + fn iif_round_trips_random_books( + (specs, oe, _) in books_strategy(), + memos in proptest::collection::vec(any_text(), 25), + ) { + let mut specs = specs; + for (s, m) in specs.iter_mut().zip(memos) { + s.memo = m; + } let b = build_books(&specs, oe); let accounts = iif_accounts(&b.ledger, qb); prop_assert_eq!(accounts.len(), b.ledger.chart().len()); let entries: Vec<&JournalEntry> = b.ledger.posted_entries().collect(); - let text = journal_to_iif(&accounts, &entries).unwrap(); - check_iif(&text, &accounts, &entries); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + check_iif(&iif.text, &accounts, &entries); + check_changes(&iif, &accounts, &entries); + // INVARIANT 4: a memo of printable ASCII is written as itself. + for (e, memo) in entries.iter().zip(iif_memos(&iif.text)) { + if plain(e.description()) { + prop_assert_eq!(memo.as_str(), e.description()); + } + } // INVARIANT 2: a subaccount's NAME is its path, parents first, one // colon per level, the parent's path as its prefix. for a in &accounts { @@ -1241,11 +1569,87 @@ proptest! { } } } + + /// iif INVARIANTS 2, 6 and 7 over random charts full of clashing + /// names: every NAME differs from every other ignoring case, has one + /// colon per level under its parent's NAME, and each level is the + /// account's own name (or that name followed by " (ACCNUM)" where it + /// would clash), trimmed, never empty; a clash that remains is refused, + /// naming accounts whose NAMEs are alike; every change is listed. + #[test] + fn iif_names_never_clash_and_every_change_is_listed(chart in chart_strategy()) { + let mut l = Ledger::new(Currency::USD); + let mut ids = Vec::new(); + // Codes: most four digits; some with a colon (its tag can't add a + // level), some past NUMBER_MAX (left out of ACCNUM), and one with a + // space before account 2's (the same number once trimmed: both + // left out, and tagged alike). + let code_of = |i: usize| match i { + 8 => " 6002".to_string(), + i if i % 5 == 3 => format!("{}:{i}", 60 + i), + i if i % 7 == 6 => format!("1234567{i}"), + i => format!("{}", 6000 + i), + }; + for (i, (name, parent)) in chart.iter().enumerate() { + // A blank name is refused by the books; it can't reach the file. + let name = if name.trim().is_empty() { "x" } else { name.as_str() }; + let parent = parent.map(|p| ids[p]); + ids.push(l.add_account(&code_of(i), name, AccountType::Expense, parent, false).unwrap()); + } + let accounts = iif_accounts(&l, |_| QbAccountType::Expense); + match journal_to_iif(&accounts, &[]) { + Ok(iif) => { + check_iif(&iif.text, &accounts, &[]); + check_changes(&iif, &accounts, &[]); + for a in &accounts { + let acct = l.chart().get(a.id).unwrap(); + prop_assert_eq!(a.path.matches(':').count(), depth(&l, a.id), "{:?}", a.path); + if let Some(p) = acct.parent { + let pp = &accounts.iter().find(|x| x.id == p).unwrap().path; + prop_assert!(a.path.starts_with(&format!("{pp}:")), "{:?} under {:?}", a.path, pp); + } + let own = a.path.rsplit(':').next().unwrap(); + prop_assert!(!own.is_empty() && own == own.trim(), "{:?}", own); + prop_assert!(own.len() <= NAME_MAX, "{:?}", own); + // A plain name written as itself, cut to NAME_MAX, or + // so cut to make room for its tag, unless it clashed. + let name = acct.name.as_str(); + let tag = format!(" ({})", acct.code.trim().replace(':', "-")); + if plain(name) && !name.contains(':') && name == name.trim() { + let base = own.strip_suffix(tag.as_str()).unwrap_or(own); + prop_assert!( + own == name.get(..NAME_MAX).unwrap_or(name).trim_end() + || (own.ends_with(tag.as_str()) && name.starts_with(base)), + "{:?} for {:?}", own, name + ); + } + } + } + Err(IifError::NameCollision { name, codes }) => { + // Only account 8, whose code is account 2's once trimmed, + // can be tagged like another. + let alike: Vec<&str> = accounts + .iter() + .filter(|a| field_eq(&a.path, &name)) + .map(|a| a.code.as_str()) + .collect(); + prop_assert_eq!(&alike, &codes); + prop_assert_eq!(codes.iter().map(|c| c.trim()).collect::>().len(), 1); + } + Err(e) => prop_assert!(false, "{}", e), + } + } +} + +/// Whether two NAMEs are alike to QuickBooks (ignoring ASCII case). +fn field_eq(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) } /// iif INVARIANT 2: "Reserve: Savings" under "Cash" is one level down, its -/// own colon replaced; INVARIANT 4: common punctuation is transliterated -/// (curly quotes, em dash), anything else becomes '?'. +/// own colon replaced; INVARIANT 4: what isn't ASCII is transliterated +/// (curly quotes straight and single, an em dash two hyphens, "é" "e"), +/// and each such name is listed (INVARIANT 7). #[test] fn iif_paths_and_transliteration() { let b = build_books(&[], true); @@ -1254,29 +1658,34 @@ fn iif_paths_and_transliteration() { let id = b.ledger.chart().find_by_code(code).unwrap().id; accounts.iter().find(|a| a.id == id).unwrap().path.clone() }; - let reserve = path("1010"); - assert!(reserve.starts_with("Cash:"), "{reserve:?}"); - assert_eq!(reserve.matches(':').count(), 1, "{reserve:?}"); - assert!(reserve.ends_with("Savings")); + assert_eq!(path("1010"), "Cash:Reserve- Savings"); for a in &accounts { - assert!(a.path.is_ascii(), "{:?}", a.path); + assert!(plain(&a.path), "{:?}", a.path); } - // Curly quotes are common punctuation: transliterated, not '?'. - assert_eq!(path("4100"), "Interest \"income\""); - // The em dash too. - let cap = path("3000"); - assert!( - cap.starts_with("Capital ") && cap.ends_with(" Ada"), - "{cap:?}" + // Curly quotes straight, and a double quote single (QuickBooks reads + // it as a text delimiter). + assert_eq!(path("4100"), "Interest 'income'"); + assert_eq!(path("3000"), "Capital -- Ada"); + assert_eq!(path("6010"), "Cafe meals"); + let iif = journal_to_iif(&accounts, &[]).unwrap(); + let changed: Vec = iif.changes.iter().map(ToString::to_string).collect(); + assert_eq!( + changed, + [ + "account 1010's name \"Reserve: Savings\" is written \"Reserve- Savings\"", + "account 3000's name \"Capital — Ada\" is written \"Capital -- Ada\"", + "account 3001's name \"Capital — Grace\" is written \"Capital -- Grace\"", + "account 3100's name \"Distributions — Ada\" is written \"Distributions -- Ada\"", + "account 3101's name \"Distributions — Grace\" is written \"Distributions -- Grace\"", + "account 4100's name \"Interest “income”\" is written \"Interest 'income'\"", + "account 6010's name \"Café meals\" is written \"Cafe meals\"", + ] ); - assert!(!cap.contains('?'), "em dash became '?': {cap:?}"); - // A letter with no ASCII form is not punctuation: '?'. - assert_eq!(path("6010"), "Caf? meals"); } /// iif layout example (module docs): a two-line sale on 1/15/2026 writes -/// TRNS Cash 1000.00 then SPL Revenue -1000.00, DOCNUM 1, memo "Sale", each -/// line's tabs matching its header. +/// TRNS Cash 1000.00 then SPL Revenue -1000.00, DOCNUM 1 (it is entry 1), +/// memo "Sale", each line's tabs matching its header. #[test] fn iif_module_layout_example() { let mut l = Ledger::new(Currency::USD); @@ -1287,6 +1696,7 @@ fn iif_module_layout_example() { .add_account("4000", "Revenue", AccountType::Revenue, None, false) .unwrap(); let e = l.create_entry(d(2026, 1, 15), "Sale"); + assert_eq!(e, 1); l.add_line(e, cash, Side::Debit, usd(100_000)).unwrap(); l.add_line(e, rev, Side::Credit, usd(100_000)).unwrap(); l.post_entry(e).unwrap(); @@ -1298,14 +1708,15 @@ fn iif_module_layout_example() { } }); let entries: Vec<&JournalEntry> = l.posted_entries().collect(); - let text = journal_to_iif(&accounts, &entries).unwrap(); - let lines: Vec<&str> = text.lines().map(|l| l.trim_end_matches('\r')).collect(); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + let lines: Vec<&str> = iif.text.lines().map(|l| l.trim_end_matches('\r')).collect(); assert_eq!(lines[0], "!ACCNT\tNAME\tACCNTTYPE\tACCNUM"); assert!(lines.contains(&"ACCNT\tCash\tBANK\t1000")); assert!(lines.contains(&"ACCNT\tRevenue\tINC\t4000")); assert!(lines.contains(&"TRNS\t\tGENERAL JOURNAL\t1/15/2026\tCash\t\t1000.00\t1\tSale")); assert!(lines.contains(&"SPL\t\tGENERAL JOURNAL\t1/15/2026\tRevenue\t\t-1000.00\t1\tSale")); - check_iif(&text, &accounts, &entries); + check_iif(&iif.text, &accounts, &entries); + assert!(iif.changes.is_empty()); } /// iif INVARIANT 4: a memo holding tabs, line breaks, and non-ASCII still @@ -1325,8 +1736,9 @@ fn iif_memo_with_tabs_and_breaks() { l.post_entry(e).unwrap(); let accounts = iif_accounts(&l, |_| QbAccountType::Bank); let entries: Vec<&JournalEntry> = l.posted_entries().collect(); - let text = journal_to_iif(&accounts, &entries).unwrap(); - check_iif(&text, &accounts, &entries); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + check_iif(&iif.text, &accounts, &entries); + check_changes(&iif, &accounts, &entries); } /// iif INVARIANT 5: an entry whose account wasn't declared is refused, @@ -1356,6 +1768,689 @@ fn iif_refuses_an_undeclared_account() { ); } +/// A ledger with Cash 1000, Revenue 4000 and Fees 6000. +fn small_ledger() -> (Ledger, AccountId, AccountId, AccountId) { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let rev = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let fees = l + .add_account("6000", "Fees", AccountType::Expense, None, false) + .unwrap(); + (l, cash, rev, fees) +} + +/// Defect 7 (P3; DOCNUM was the transaction's place in the file, so a +/// discarded draft, a post out of order or the as_of filter broke the +/// trace from QuickBooks to the books). iif INVARIANT 3: DOCNUM is the +/// entry's id, on the TRNS and every SPL. Entry 1 is a draft never +/// posted; 2, 3 and 4 are posted in the order 3, 2, 4, and dated so that +/// date order (4, 3, 2), posting order and id order all differ. The file +/// had numbered them 1, 2, 3. +#[test] +fn iif_docnum_is_the_entry_id() { + let (mut l, cash, rev, fees) = small_ledger(); + let draft = l.create_entry(d(2026, 1, 10), "Never posted"); + l.add_line(draft, fees, Side::Debit, usd(500)).unwrap(); + l.add_line(draft, cash, Side::Credit, usd(500)).unwrap(); + let b = l.create_entry(d(2026, 3, 1), "March sale"); + l.add_line(b, cash, Side::Debit, usd(20_000)).unwrap(); + l.add_line(b, rev, Side::Credit, usd(20_000)).unwrap(); + let c = l.create_entry(d(2026, 2, 1), "February sale, less a fee"); + l.add_line(c, cash, Side::Debit, usd(9_700)).unwrap(); + l.add_line(c, fees, Side::Debit, usd(300)).unwrap(); + l.add_line(c, rev, Side::Credit, usd(10_000)).unwrap(); + let e4 = l.create_entry(d(2026, 1, 20), "January fee"); + l.add_line(e4, fees, Side::Debit, usd(1_000)).unwrap(); + l.add_line(e4, cash, Side::Credit, usd(1_000)).unwrap(); + assert_eq!((draft, b, c, e4), (1, 2, 3, 4)); + for e in [c, b, e4] { + l.post_entry(e).unwrap(); + } + let accounts = iif_accounts(&l, |_| QbAccountType::Bank); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + assert_eq!( + entries.iter().map(|e| e.id()).collect::>(), + [3, 2, 4] + ); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + check_iif(&iif.text, &accounts, &entries); + // Each record's keyword and DOCNUM, in file order. + let docnums: Vec<(&str, &str)> = iif + .text + .split("\r\n") + .filter(|l| l.starts_with("TRNS\t") || l.starts_with("SPL\t")) + .map(|l| { + let f: Vec<&str> = l.split('\t').collect(); + (f[0], f[7]) + }) + .collect(); + assert_eq!( + docnums, + [ + ("TRNS", "3"), + ("SPL", "3"), + ("SPL", "3"), + ("TRNS", "2"), + ("SPL", "2"), + ("TRNS", "4"), + ("SPL", "4"), + ] + ); +} + +/// Plan D-36 (P3, defect 7): the file is plain ASCII, every name and memo +/// past ASCII transliterated and listed. Accented letters, Chinese, +/// emoji, a fullwidth colon and fullwidth digits, a tab inside a name, +/// controls, a decomposed accent and a private-use character, each with +/// its exact written form (deunicode 1.6.0's table); a memo of plain ASCII +/// is neither changed nor listed. Before, every one of these characters +/// was '?' (and "—" a single '-'), and nothing said so. +#[test] +fn iif_writes_plain_ascii_and_lists_every_change() { + let (mut l, cash, rev, _) = small_ledger(); + for (code, name) in [ + ("6010", "Café meals"), + ("6020", "北京 Office"), + ("6030", "Ratio\u{FF1A}Ops"), + ("6040", "Desk\trent"), + ("6050", "René 😀"), + ("6070", "Wide number"), + ] { + l.add_account(code, name, AccountType::Expense, None, false) + .unwrap(); + } + let memos = [ + "Lunch at Café “Zoë” — 北京\t☕\n", + "Control\u{1}char\u{7F}", + "Plain ASCII memo", + "Cafe\u{301}", + "\u{E000}x", + ]; + for m in memos { + let e = l.create_entry(d(2026, 4, 1), m); + l.add_line(e, cash, Side::Debit, usd(100)).unwrap(); + l.add_line(e, rev, Side::Credit, usd(100)).unwrap(); + l.post_entry(e).unwrap(); + } + let accounts = iif_accounts(&l, |_| QbAccountType::Expense); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + check_iif(&iif.text, &accounts, &entries); + check_changes(&iif, &accounts, &entries); + let accnt: Vec<&str> = iif + .text + .split("\r\n") + .filter(|l| l.starts_with("ACCNT\t")) + .collect(); + assert_eq!( + accnt, + [ + "ACCNT\tCash\tEXP\t1000", + "ACCNT\tRevenue\tEXP\t4000", + "ACCNT\tFees\tEXP\t6000", + "ACCNT\tCafe meals\tEXP\t6010", + "ACCNT\tBei Jing Office\tEXP\t6020", + "ACCNT\tRatio-Ops\tEXP\t6030", + "ACCNT\tDesk rent\tEXP\t6040", + "ACCNT\tRene grinning\tEXP\t6050", + "ACCNT\tWide number\tEXP\t6070", + ] + ); + assert_eq!( + iif_memos(&iif.text), + [ + "Lunch at Cafe 'Zoe' -- Bei Jing coffee ", + "Control?char?", + "Plain ASCII memo", + "Cafe", + "?x", + ] + ); + let changed: Vec = iif.changes.iter().map(ToString::to_string).collect(); + assert_eq!( + changed, + [ + "account 6010's name \"Café meals\" is written \"Cafe meals\"", + "account 6020's name \"北京 Office\" is written \"Bei Jing Office\"", + "account 6030's name \"Ratio:Ops\" is written \"Ratio-Ops\"", + "account 6040's name \"Desk\\trent\" is written \"Desk rent\"", + "account 6050's name \"René 😀\" is written \"Rene grinning\"", + "account number \"6070\" is written \"6070\"", + "entry 1's memo \"Lunch at Café “Zoë” — 北京\\t☕\\n\" is written \"Lunch at Cafe 'Zoe' -- Bei Jing coffee \"", + "entry 2's memo \"Control\\u{1}char\\u{7f}\" is written \"Control?char?\"", + "entry 4's memo \"Cafe\\u{301}\" is written \"Cafe\"", + "entry 5's memo \"\\u{e000}x\" is written \"?x\"", + ] + ); +} + +/// Plan D-36: every line of the file ends CR LF, as in Intuit's own +/// example file (sources/intuit-iif.md), and no CR or LF stands alone. +#[test] +fn iif_every_line_ends_cr_lf() { + let (mut l, cash, rev, _) = small_ledger(); + let e = l.create_entry(d(2026, 5, 1), "Sale\r\nsplit\rover\nlines"); + l.add_line(e, cash, Side::Debit, usd(100)).unwrap(); + l.add_line(e, rev, Side::Credit, usd(100)).unwrap(); + l.post_entry(e).unwrap(); + let accounts = iif_accounts(&l, |_| QbAccountType::Bank); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let text = journal_to_iif(&accounts, &entries).unwrap().text; + let crlf = text.matches("\r\n").count(); + assert_eq!(crlf, text.matches('\n').count(), "every LF follows a CR"); + assert_eq!( + crlf, + text.matches('\r').count(), + "every CR comes before an LF" + ); + // Three headers and three accounts, three more headers, TRNS, SPL, + // ENDTRNS. + assert_eq!(crlf, 1 + 3 + 3 + 3); + assert!(text.ends_with("ENDTRNS\r\n")); + assert_eq!(iif_memos(&text), ["Sale split over lines"]); +} + +/// iif INVARIANT 6 (P3 review R2): QuickBooks knows an account by its +/// NAME, so two that would share one would be merged. Two top-level +/// "Software", "software" by case, two "Licenses" under one parent, a +/// fullwidth colon against a hyphen, two private-use names (both '?'): +/// each written with " (ACCNUM)", the subaccounts of a renamed parent +/// following it, and listed. A name that is another's tagged form is +/// tagged in turn; a match left when the codes themselves are written +/// alike is refused, naming both. +#[test] +fn iif_distinct_accounts_never_share_a_name() { + let mut l = Ledger::new(Currency::USD); + let mut add = |code: &str, name: &str, parent: Option<&str>| { + let p = parent.map(|c| l.chart().find_by_code(c).unwrap().id); + l.add_account(code, name, AccountType::Expense, p, false) + .unwrap(); + }; + add("6100", "Software", None); + add("6110", "Licenses", Some("6100")); + add("6200", "Software", None); + add("6210", "Licenses", Some("6200")); + add("6300", "software", None); + add("6400", "Subscriptions", None); + add("6410", "Licenses", Some("6400")); + add("6420", "Licenses", Some("6400")); + add("6500", "Ratio\u{FF1A}Ops", None); + add("6600", "Ratio-Ops", None); + add("6700", "\u{E000}", None); + add("6800", "\u{E001}", None); + let accounts = iif_accounts(&l, |_| QbAccountType::Expense); + let paths: Vec<(&str, &str)> = accounts + .iter() + .map(|a| (a.number.as_str(), a.path.as_str())) + .collect(); + assert_eq!( + paths, + [ + ("6100", "Software (6100)"), + ("6110", "Software (6100):Licenses"), + ("6200", "Software (6200)"), + ("6210", "Software (6200):Licenses"), + ("6300", "software (6300)"), + ("6400", "Subscriptions"), + ("6410", "Subscriptions:Licenses (6410)"), + ("6420", "Subscriptions:Licenses (6420)"), + ("6500", "Ratio-Ops (6500)"), + ("6600", "Ratio-Ops (6600)"), + ("6700", "? (6700)"), + ("6800", "? (6800)"), + ] + ); + let iif = journal_to_iif(&accounts, &[]).unwrap(); + check_iif(&iif.text, &accounts, &[]); + check_changes(&iif, &accounts, &[]); + assert_eq!(iif.changes.len(), 9, "{:?}", iif.changes); + assert_eq!( + iif.changes[0].to_string(), + "account 6100's name \"Software\" is written \"Software (6100)\"" + ); + // A third account already named like the first's tagged form: tagged + // in its turn, so no two match. + l.add_account("6900", "Software (6100)", AccountType::Expense, None, false) + .unwrap(); + let accounts = iif_accounts(&l, |_| QbAccountType::Expense); + let path = |code: &str| { + accounts + .iter() + .find(|a| a.code == code) + .map(|a| a.path.clone()) + .unwrap() + }; + assert_eq!(path("6100"), "Software (6100)"); + assert_eq!(path("6900"), "Software (6100) (6900)"); + let iif = journal_to_iif(&accounts, &[]).unwrap(); + check_iif(&iif.text, &accounts, &[]); + check_changes(&iif, &accounts, &[]); + // Two "Software" whose codes are written alike (a fullwidth digit): + // tagged alike, so the match remains, and is refused naming both. + let mut l = Ledger::new(Currency::USD); + for code in ["6100", "6\u{FF11}00"] { + l.add_account(code, "Software", AccountType::Expense, None, false) + .unwrap(); + } + let accounts = iif_accounts(&l, |_| QbAccountType::Expense); + let err = journal_to_iif(&accounts, &[]).unwrap_err(); + assert_eq!( + err, + IifError::NameCollision { + name: "Software (6100)".into(), + codes: vec!["6100".into(), "6\u{FF11}00".into()], + } + ); + assert_eq!( + err.to_string(), + "accounts 6100, 6\u{FF11}00 would have the same name in QuickBooks, \"Software (6100)\": rename one" + ); +} + +/// iif INVARIANT 2 (P3 review round 2): QuickBooks gives a subaccount +/// its parent's type ("The parent and subaccount must be the same account +/// type", Intuit, sources/intuit-iif.md), so a bank account under a +/// "Bank accounts" header that is no bank, or an asset's cost account +/// under a "Fixed assets" header, would import as the wrong type or not at +/// all. Each is written at the top level, keeping its own type, its own +/// subaccounts with it, and listed; one that then matches a top-level +/// account's name is told apart by its code; a subaccount of its parent's +/// type stays under it. Before, every subaccount was written under its +/// parent whatever their types. +#[test] +fn iif_a_subaccount_of_another_type_stands_at_the_top_level() { + let mut l = Ledger::new(Currency::USD); + let mut add = |code: &str, name: &str, parent: Option<&str>| { + let p = parent.map(|c| l.chart().find_by_code(c).unwrap().id); + l.add_account(code, name, AccountType::Asset, p, false) + .unwrap() + }; + add("1000", "Bank accounts", None); + let checking = add("1010", "Checking", Some("1000")); + let sweep = add("1011", "Sweep", Some("1010")); + add("1020", "Petty cash", Some("1000")); + add("1500", "Fixed assets", None); + let laptop = add("1510", "Laptop", Some("1500")); + add("1600", "Laptop", None); + let e = l.create_entry(d(2026, 3, 1), "Laptop bought"); + l.add_line(e, laptop, Side::Debit, usd(150_000)).unwrap(); + l.add_line(e, sweep, Side::Credit, usd(100_000)).unwrap(); + l.add_line(e, checking, Side::Credit, usd(50_000)).unwrap(); + l.post_entry(e).unwrap(); + let accounts = iif_accounts(&l, |a| match a.code.as_str() { + "1010" | "1011" => QbAccountType::Bank, + "1510" => QbAccountType::FixedAsset, + _ => QbAccountType::OtherCurrentAsset, + }); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + check_iif(&iif.text, &accounts, &entries); + check_changes(&iif, &accounts, &entries); + let lines: Vec<&str> = iif + .text + .split("\r\n") + .filter(|l| l.starts_with("ACCNT\t") || l.starts_with("TRNS\t") || l.starts_with("SPL\t")) + .collect(); + assert_eq!( + lines, + [ + "ACCNT\tBank accounts\tOCASSET\t1000", + "ACCNT\tChecking\tBANK\t1010", + "ACCNT\tChecking:Sweep\tBANK\t1011", + "ACCNT\tBank accounts:Petty cash\tOCASSET\t1020", + "ACCNT\tFixed assets\tOCASSET\t1500", + "ACCNT\tLaptop (1510)\tFIXASSET\t1510", + "ACCNT\tLaptop (1600)\tOCASSET\t1600", + "TRNS\t\tGENERAL JOURNAL\t3/1/2026\tLaptop (1510)\t\t1500.00\t1\tLaptop bought", + "SPL\t\tGENERAL JOURNAL\t3/1/2026\tChecking:Sweep\t\t-1000.00\t1\tLaptop bought", + "SPL\t\tGENERAL JOURNAL\t3/1/2026\tChecking\t\t-500.00\t1\tLaptop bought", + ] + ); + assert_eq!( + iif.warnings(), + [ + "account 1010's name is written at the top level, not under account 1000's: QuickBooks gives a subaccount its parent's type, and it is BANK, its parent OCASSET", + "account 1510's name \"Laptop\" is written \"Laptop (1510)\"", + "account 1510's name is written at the top level, not under account 1500's: QuickBooks gives a subaccount its parent's type, and it is FIXASSET, its parent OCASSET", + "account 1600's name \"Laptop\" is written \"Laptop (1600)\"", + ] + ); + assert!(iif.receivable_payable.is_empty()); +} + +/// iif INVARIANT 1 (P3 review round 2): QuickBooks reads a subaccount's +/// NAME as its parent's and its own, so each parent is declared before its +/// subaccounts, else the accounts go in the order of their codes. A parent +/// coded after its subaccounts ("6000 Office" over "5900 Supplies" over +/// "5800 Paper") had been declared after them. +#[test] +fn iif_declares_each_parent_before_its_subaccounts() { + let mut l = Ledger::new(Currency::USD); + let mut add = |code: &str, name: &str, parent: Option<&str>| { + let p = parent.map(|c| l.chart().find_by_code(c).unwrap().id); + l.add_account(code, name, AccountType::Expense, p, false) + .unwrap(); + }; + add("6000", "Office", None); + add("5900", "Supplies", Some("6000")); + add("5800", "Paper", Some("5900")); + add("5000", "Rent", None); + add("6100", "Software", None); + add("5950", "Toner", Some("5900")); + let accounts = iif_accounts(&l, |_| QbAccountType::Expense); + let iif = journal_to_iif(&accounts, &[]).unwrap(); + check_iif(&iif.text, &accounts, &[]); + let names: Vec<&str> = iif + .text + .split("\r\n") + .filter_map(|l| l.strip_prefix("ACCNT\t")) + .map(|l| l.split('\t').next().unwrap()) + .collect(); + assert_eq!( + names, + [ + "Rent", + "Office", + "Office:Supplies", + "Office:Supplies:Paper", + "Office:Supplies:Toner", + "Software", + ] + ); + assert!(iif.changes.is_empty()); +} + +/// P3 review R12: a change prints as one line, whatever the books' text +/// holds: its line breaks and controls are escaped. +#[test] +fn iif_change_warnings_are_one_line_each() { + let (mut l, cash, rev, _) = small_ledger(); + let e = l.create_entry(d(2026, 6, 1), "a\nb\u{1}c\u{2028}d"); + l.add_line(e, cash, Side::Debit, usd(100)).unwrap(); + l.add_line(e, rev, Side::Credit, usd(100)).unwrap(); + l.post_entry(e).unwrap(); + let accounts = iif_accounts(&l, |_| QbAccountType::Bank); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + assert_eq!( + iif.changes + .iter() + .map(ToString::to_string) + .collect::>(), + ["entry 1's memo \"a\\nb\\u{1}c\\u{2028}d\" is written \"a b?c d\""] + ); +} + +/// QuickBooks Desktop's limits (Intuit, "Character limitations for fields +/// in QuickBooks": account name 31, account number 7, memo 4,095; +/// sources/intuit-iif.md). iif INVARIANT 2: a level past 31 characters is +/// cut there, and the clash check runs on the cut names, so two alike once +/// cut are tagged with their codes (cut to make room), as is one whose cut +/// form is another's own name; a subaccount follows its cut parent. +/// INVARIANT 6: an account number past 7 characters, or written like +/// another's, is left out of ACCNUM. INVARIANT 3: a memo past 4,095 +/// characters is cut. Every one listed, in the exact words. +#[test] +fn iif_fits_quickbooks_desktop_limits() { + let mut l = Ledger::new(Currency::USD); + let mut add = |code: &str, name: &str, parent: Option<&str>| { + let p = parent.map(|c| l.chart().find_by_code(c).unwrap().id); + l.add_account(code, name, AccountType::Expense, p, false) + .unwrap() + }; + let fees = add("6100", "Professional services and consulting fees", None); + add("6110", "Legal", Some("6100")); + add("6200", "Professional services and consulting costs", None); + add("6300", "Professional services and consu", None); + add("6400", "Office supplies and small equipment", None); + add("12345678", "Suspense", None); + // Seven characters, the most: kept; two alike but for case: left out. + add("1234567", "Escrow", None); + add("CLR0001", "Clearing", None); + add("clr0001", "Clearing, old", None); + add("6500", "Bank fees", None); + add(" 6500", "Card fees", None); + let cash = add("1000", "Cash", None); + let long_memo = format!("{}\u{2014}tail", "x".repeat(MEMO_MAX - 1)); + let e = l.create_entry(d(2026, 7, 1), &long_memo); + l.add_line(e, fees, Side::Debit, usd(100)).unwrap(); + l.add_line(e, cash, Side::Credit, usd(100)).unwrap(); + l.post_entry(e).unwrap(); + let accounts = iif_accounts(&l, |_| QbAccountType::Expense); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + check_iif(&iif.text, &accounts, &entries); + check_changes(&iif, &accounts, &entries); + let accnt: Vec<&str> = iif + .text + .split("\r\n") + .filter(|l| l.starts_with("ACCNT\t")) + .collect(); + assert_eq!( + accnt, + [ + "ACCNT\tCard fees\tEXP\t", + "ACCNT\tCash\tEXP\t1000", + "ACCNT\tEscrow\tEXP\t1234567", + "ACCNT\tSuspense\tEXP\t", + "ACCNT\tProfessional services an (6100)\tEXP\t6100", + "ACCNT\tProfessional services an (6100):Legal\tEXP\t6110", + "ACCNT\tProfessional services an (6200)\tEXP\t6200", + "ACCNT\tProfessional services an (6300)\tEXP\t6300", + "ACCNT\tOffice supplies and small equip\tEXP\t6400", + "ACCNT\tBank fees\tEXP\t", + "ACCNT\tClearing\tEXP\t", + "ACCNT\tClearing, old\tEXP\t", + ] + ); + for l in &accnt { + let name = l.split('\t').nth(1).unwrap(); + assert!( + name.split(':').all(|level| level.len() <= NAME_MAX), + "{name:?}" + ); + } + let memo = &iif_memos(&iif.text)[0]; + assert_eq!(memo.len(), MEMO_MAX); + assert_eq!(*memo, format!("{}-", "x".repeat(MEMO_MAX - 1))); + assert_eq!( + iif.warnings(), + [ + "account number \" 6500\" is left out: another account's number would also be written \"6500\"".to_string(), + "account number \"12345678\" is left out: QuickBooks Desktop holds 7 characters at most".to_string(), + "account 6100's name \"Professional services and consulting fees\" is written \"Professional services an (6100)\"".to_string(), + "account 6200's name \"Professional services and consulting costs\" is written \"Professional services an (6200)\"".to_string(), + "account 6300's name \"Professional services and consu\" is written \"Professional services an (6300)\"".to_string(), + "account 6400's name \"Office supplies and small equipment\" is written \"Office supplies and small equip\"".to_string(), + "account number \"6500\" is left out: another account's number would also be written \"6500\"".to_string(), + "account number \"CLR0001\" is left out: another account's number would also be written \"CLR0001\"".to_string(), + "account number \"clr0001\" is left out: another account's number would also be written \"clr0001\"".to_string(), + format!("\"\u{2014}\" is written \"-\" in the memo of entry {e}"), + format!("the memo of entry {e} is cut to 4095 characters, the most QuickBooks Desktop holds"), + ] + ); + // A NAME built some other way, a level past the limit: refused. + let mut bad = accounts.clone(); + bad[1].path = "x".repeat(NAME_MAX + 1); + assert_eq!( + journal_to_iif(&bad, &[]).unwrap_err(), + IifError::NameTooLong { + code: bad[1].code.clone(), + name: "x".repeat(NAME_MAX + 1), + } + ); + assert_eq!((NAME_MAX, NUMBER_MAX, MEMO_MAX), (31, 7, 4095)); +} + +/// The README and the export's warnings group the memos (lead's call, +/// 2026-10-20): each thing written differently in them is one line with +/// every entry it is in, so a hundred depreciation memos with an em dash +/// make one line, not a hundred; a run of changed characters is one thing +/// ("北京" is "Bei Jing"); the lines come in the order first met, the +/// entries in order, each once; account names and numbers stay one line +/// each, first. No change is left out. +#[test] +fn iif_warnings_group_the_memos_by_what_is_written_differently() { + let (mut l, cash, _, fees) = small_ledger(); + l.add_account( + "6200", + "Caf\u{E9} \u{2014} north", + AccountType::Expense, + None, + false, + ) + .unwrap(); + let post = |l: &mut Ledger, memo: &str| { + let e = l.create_entry(d(2026, 2, 1), memo); + l.add_line(e, fees, Side::Debit, usd(100)).unwrap(); + l.add_line(e, cash, Side::Credit, usd(100)).unwrap(); + l.post_entry(e).unwrap(); + e + }; + let mut depreciation = Vec::new(); + for m in 1..=100 { + depreciation.push(post( + &mut l, + &format!("Depreciation \u{2014} Laptop \u{2014} {m}"), + )); + } + let beijing = post(&mut l, "Trip to \u{5317}\u{4EAC} \u{2014} visa"); + let plain_one = post(&mut l, "Plain memo"); + let zoe = post(&mut l, "Lunch, \"Zo\u{EB}\"\nteam \u{5317}\u{4EAC}"); + let accounts = iif_accounts(&l, |_| QbAccountType::Expense); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + check_changes(&iif, &accounts, &entries); + assert_eq!(iif.changes.len(), 1 + 100 + 1 + 1, "one per name and memo"); + assert!( + !iif.changes + .iter() + .any(|c| c.field == IifField::Memo { entry: plain_one }) + ); + let mut dashes = depreciation.clone(); + dashes.push(beijing); + let list = |ids: &[u64]| { + ids.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + }; + assert_eq!( + iif.warnings(), + [ + "account 6200's name \"Café — north\" is written \"Cafe -- north\"".to_string(), + format!( + "\"—\" is written \"--\" in the memos of entries {}", + list(&dashes) + ), + format!("\"北京\" is written \"Bei Jing\" in the memos of entries {beijing}, {zoe}"), + format!("\"\\\"\" is written \"'\" in the memo of entry {zoe}"), + // "ë", the quote and the line break are side by side: one run. + format!("\"ë\\\"\\n\" is written \"e' \" in the memo of entry {zoe}"), + ] + ); +} + +/// INVARIANT 7: no warning holds a full TIN (rules.md B-26), though entry +/// descriptions and account names aren't checked for one: a memo's TIN +/// written with en dashes shows only the dashes; one in fullwidth digits, +/// one in Arabic-Indic digits, and an account name holding one, are +/// masked, digit by digit, and a line with digits masked says so; so is +/// the change's own line. So are a name and a memo that hold a TIN only +/// once written (digits a tab apart, which the file writes a space apart). +#[test] +fn iif_warnings_never_hold_a_tin() { + let (mut l, cash, rev, _) = small_ledger(); + l.add_account( + "6300", + "Loan from 123-45-6789 \u{2014} member", + AccountType::Liability, + None, + false, + ) + .unwrap(); + // A TIN only once written: its digits a tab apart, which the file + // writes a space apart. + l.add_account( + "6400", + "Loan 123\t45\t6789", + AccountType::Liability, + None, + false, + ) + .unwrap(); + for memo in [ + "Paid SSN 123\u{2013}45\u{2013}6789", + "Paid \u{FF11}\u{FF12}\u{FF13}\u{FF0D}\u{FF14}\u{FF15}\u{FF0D}\u{FF16}\u{FF17}\u{FF18}\u{FF19}", + // Arabic-Indic digits, spaced em dashes: a TIN as the books keep it, + // though " -- " is too wide a gap once written. + "Ref \u{661}\u{662}\u{663} \u{2014} \u{664}\u{665} \u{2014} \u{666}\u{667}\u{668}\u{669}", + // Arabic-Indic digits a tab apart: a TIN only once written. + "Ref \u{661}\u{662}\u{663}\t\u{664}\u{665}\t\u{666}\u{667}\u{668}\u{669}", + ] { + let e = l.create_entry(d(2026, 6, 1), memo); + l.add_line(e, cash, Side::Debit, usd(100)).unwrap(); + l.add_line(e, rev, Side::Credit, usd(100)).unwrap(); + l.post_entry(e).unwrap(); + } + let accounts = iif_accounts(&l, |_| QbAccountType::Bank); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + check_changes(&iif, &accounts, &entries); + assert_eq!( + iif.warnings(), + [ + "account 6300's name \"Loan from •••-••-•••• — member\" is written \"Loan from •••-••-•••• -- member\"", + "account 6400's name \"Loan •••\\t••\\t••••\" is written \"Loan ••• •• ••••\"", + "\"–\" is written \"-\" in the memo of entry 1", + "\"•••-••-••••\" is written \"•••-••-••••\" in the memo of entry 2 (digits masked: may be a TIN)", + "\"•••\" is written \"•••\" in the memo of entry 3 (digits masked: may be a TIN)", + "\"—\" is written \"--\" in the memo of entry 3", + "\"••\" is written \"••\" in the memo of entry 3 (digits masked: may be a TIN)", + "\"••••\" is written \"••••\" in the memo of entry 3 (digits masked: may be a TIN)", + "\"•••\\t••\\t••••\" is written \"••• •• ••••\" in the memo of entry 4 (digits masked: may be a TIN)", + ] + ); + assert_eq!( + iif.changes[3].to_string(), + "entry 2's memo \"Paid •••-••-••••\" is written \"Paid •••-••-••••\"" + ); + for c in &iif.changes { + assert!(!ledger_core::parties::has_full_tin(&c.to_string()), "{c}"); + } +} + +/// P3 review R7: the server records a package file holding a full TIN +/// (ledger_core::parties::has_full_tin) so it can't be filed back in. A +/// memo's TIN separated by en dashes or no-break spaces stays a TIN in +/// the file: they become ASCII hyphens and spaces, never '?'. +#[test] +fn iif_keeps_a_tin_readable_to_the_package_scan() { + let (mut l, cash, rev, _) = small_ledger(); + for memo in [ + "Paid SSN 123\u{2013}45\u{2013}6789", + "Paid SSN 123\u{A0}45\u{A0}6789", + ] { + assert!(ledger_core::parties::has_full_tin(memo)); + let e = l.create_entry(d(2026, 6, 1), memo); + l.add_line(e, cash, Side::Debit, usd(100)).unwrap(); + l.add_line(e, rev, Side::Credit, usd(100)).unwrap(); + l.post_entry(e).unwrap(); + } + let accounts = iif_accounts(&l, |_| QbAccountType::Bank); + let entries: Vec<&JournalEntry> = l.posted_entries().collect(); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + let memos = iif_memos(&iif.text); + assert_eq!(memos, ["Paid SSN 123-45-6789", "Paid SSN 123 45 6789"]); + for m in &memos { + assert!(ledger_core::parties::has_full_tin(m), "{m}"); + } +} + /// QbAccountType::keyword: the IIF ACCNTTYPE keywords the doc comments name. #[test] fn qb_account_type_keywords() { @@ -2126,6 +3221,215 @@ fn pdf_each_markup_label_alone_renders() { } } +/// Every FlateDecode stream of a PDF, inflated (zlib: a two-byte header, +/// then RFC 1951), joined as text: the content, the fonts' ToUnicode maps +/// and the object streams that hold the fonts' dictionaries. +fn pdf_streams(pdf: &[u8]) -> String { + let find = |from: usize, pat: &[u8]| { + pdf[from..] + .windows(pat.len()) + .position(|w| w == pat) + .map(|i| from + i) + }; + let mut out = String::new(); + let mut at = 0; + while let Some(s) = find(at, b"stream") { + let dict_from = pdf[..s].windows(3).rposition(|w| w == b"obj").unwrap_or(0); + let mut body = s + b"stream".len(); + if pdf[body..].starts_with(b"\r\n") { + body += 2; + } else if pdf[body..].starts_with(b"\n") { + body += 1; + } else { + // "endstream" itself, or a word holding "stream". + at = body; + continue; + } + let end = find(body, b"endstream").expect("endstream"); + let dict = String::from_utf8_lossy(&pdf[dict_from..s]); + if dict.contains("/FlateDecode") { + out.push_str(&String::from_utf8_lossy(&inflate(&pdf[body + 2..end]))); + out.push('\n'); + } + at = end + b"endstream".len(); + } + out +} + +/// statement_pdf INVARIANT 4: a company, an account and a vendor named in +/// Chinese, Japanese and Korean go into the PDF's text: the CJK font is +/// embedded, and its ToUnicode map gives back every one of their +/// characters, so they copy, search and print as written. +#[test] +fn pdf_keeps_chinese_japanese_and_korean_names() { + let names = ["北京商贸", "東京事務所", "서울상사", "ありがとう"]; + let st = report::statement( + "Profit and Loss", + "2026".to_string(), + &["Total"], + names + .iter() + .map(|n| Row { + kind: RowKind::Line, + label: format!("6100 {n}"), + amounts: vec![Some(dec("1.00"))], + currency: false, + rule_above: false, + }) + .collect(), + ); + let m = ReportMeta { + company: "株式会社 Example".into(), + ..meta() + }; + let pdf = statement_pdf(&st, &m).unwrap(); + let text = pdf_streams(&pdf).to_lowercase(); + assert!(text.contains("notosanscjk"), "the CJK font is embedded"); + for c in names.concat().chars().chain("株式会社".chars()) { + let hex = format!("<{:04x}>", u32::from(c)); + assert!(text.contains(&hex), "{c} ({hex}) maps back to text"); + } +} + +/// The text a PDF draws, each line from the top of each page down, its +/// glyphs left to right, each the text its font's ToUnicode map gives +/// back, read with hayro (the viewer's own PDF reader): what a reader +/// copies, searches and speaks. +fn pdf_lines(pdf: &[u8]) -> Vec> { + use hayro_interpret::font::GlyphRun; + use hayro_interpret::hayro_cmap::BfString; + use hayro_interpret::hayro_syntax::Pdf; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, DrawMode, DrawProps, Image, ImageDrawProps, + InterpreterCache, InterpreterSettings, SoftMask, interpret_page, + }; + use kurbo::{Affine, BezPath, Point, Rect}; + /// Each glyph drawn: its baseline (tenths of a point), x, and text. + struct Glyphs(Vec<(i64, f64, String)>); + impl<'a> Device<'a> for Glyphs { + fn draw_path(&mut self, _: &BezPath, _: DrawProps<'a>, _: &DrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph_run(&mut self, run: &GlyphRun<'_, 'a>, props: DrawProps<'a>, _: &DrawMode) { + for g in run.glyphs() { + let at = (props.transform * g.transform()) * Point::ZERO; + let text = match g.as_unicode() { + Some(BfString::Char(c)) => c.to_string(), + Some(BfString::String(s)) => s, + // A mark or a ligature's second glyph: its text is + // with the cluster's first. + None => String::new(), + }; + self.0.push(((at.y * 10.0).round() as i64, at.x, text)); + } + } + fn draw_image(&mut self, _: Image<'a, '_>, _: ImageDrawProps<'a>) {} + fn pop_clip(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = Pdf::new(pdf.to_vec()).unwrap(); + let cache = InterpreterCache::new(); + let mut out = Vec::new(); + for page in pdf.pages().iter() { + let mut ctx = Context::new( + Affine::IDENTITY, + Rect::new(0.0, 0.0, 1.0, 1.0), + &cache, + pdf.xref(), + InterpreterSettings::default(), + ); + let mut glyphs = Glyphs(Vec::new()); + interpret_page(page, &mut ctx, &mut glyphs); + // PDF space runs up the page: the highest baseline first. + let mut lines: BTreeMap, Vec<(f64, String)>> = BTreeMap::new(); + for (y, x, t) in glyphs.0 { + lines.entry(std::cmp::Reverse(y)).or_default().push((x, t)); + } + for (_, mut line) in lines { + line.sort_by(|a, b| a.0.total_cmp(&b.0)); + out.push(line.into_iter().map(|(_, t)| t).collect()); + } + } + out +} + +/// statement_pdf INVARIANT 4 (P3 review round 2): a company and accounts +/// named in Arabic, Hebrew, Thai, Devanagari and Chinese print as written +/// in the bundled Noto fallbacks, and the PDF's text reads back as the +/// books keep it, Arabic and Hebrew drawn right to left, so their glyphs +/// read from the right give the words (the Arabic shaped, its lam-alef a +/// ligature, still reads back "سلام"; with Noto Sans Arabic 2.013, whose +/// dots are marks Typst sets apart, it read "شلام" and doubled letters), +/// the others left to right. +/// Before the fallbacks, every one of these was an empty box. +#[test] +fn pdf_prints_every_bundled_script_as_written() { + let rtl = ["مكتب سلام", "שלום עולם"]; + let ltr = ["สวัสดีครับ", "नमस्ते", "北京商贸"]; + let st = report::statement( + "Profit and Loss", + "2026".to_string(), + &["Total"], + rtl.iter() + .chain(<r) + .map(|n| Row { + kind: RowKind::Line, + label: n.to_string(), + amounts: vec![Some(dec("1.00"))], + currency: false, + rule_above: false, + }) + .collect(), + ); + let m = ReportMeta { + company: "شركة Example".into(), + ..meta() + }; + let pdf = statement_pdf(&st, &m).unwrap(); + let lines = pdf_lines(&pdf); + let squeeze = |s: &str| s.replace(' ', ""); + let read = |reversed: bool| -> Vec { + lines + .iter() + .map(|l| { + let mut l = l.clone(); + if reversed { + l.reverse(); + } + squeeze(&l.concat()) + }) + .collect() + }; + let (left, right) = (read(false), read(true)); + for word in rtl.iter().chain(&["شركة"]) { + assert!( + right.iter().any(|l| l.contains(&squeeze(word))), + "{word} reads right to left: {lines:?}" + ); + assert!( + !left.iter().any(|l| l.contains(&squeeze(word))), + "{word} isn't drawn left to right" + ); + } + for word in ltr { + assert!( + left.iter().any(|l| l.contains(&squeeze(word))), + "{word} reads left to right: {lines:?}" + ); + } + // The fonts' names (BaseFont), in the PDF's objects or its streams. + let fonts = format!("{}{}", String::from_utf8_lossy(&pdf), pdf_streams(&pdf)).to_lowercase(); + for f in [ + "notosansarabicui", + "notosanshebrew", + "notosansthai", + "notosansdevanagari", + "notosanscjk", + ] { + assert!(fonts.contains(f), "{f} is embedded"); + } +} + // --------------------------------------------------------------------------- // chart_csv // --------------------------------------------------------------------------- @@ -2992,8 +4296,11 @@ fn bank_csv_day_equals_month_needs_no_order() { assert_eq!(txns[1].date, d(2026, 12, 12)); } -/// iif INVARIANT 4, exactly: a tab or line break becomes a space, dashes -/// '-', curly quotes straight, a no-break space a space, anything else '?'. +/// iif INVARIANT 4, exactly: a tab or line break is a space, and what +/// isn't ASCII is transliterated (deunicode 1.6.0's table): the en dash +/// and the minus sign '-', the em dash "--", curly quotes straight and +/// single, "é" "e", a no-break space a space. (Before plan D-36 the em +/// dash was one '-' and the "é" '?'.) #[test] fn iif_field_transliteration_exact() { let mut l = Ledger::new(Currency::USD); @@ -3012,9 +4319,8 @@ fn iif_field_transliteration_exact() { l.post_entry(e).unwrap(); let accounts = iif_accounts(&l, |_| QbAccountType::Bank); let entries: Vec<&JournalEntry> = l.posted_entries().collect(); - let text = journal_to_iif(&accounts, &entries).unwrap(); - let trns = text.lines().find(|l| l.starts_with("TRNS\t")).unwrap(); - assert_eq!(trns.rsplit('\t').next(), Some("a b c d --- \"?\" '' x y")); + let iif = journal_to_iif(&accounts, &entries).unwrap(); + assert_eq!(iif_memos(&iif.text), ["a b c d ---- 'e' '' x y"]); } /// The tax summary shows its cost-of-goods and not-yet-categorized diff --git a/crates/ledger-mcp/handbook/cpa-handoff.md b/crates/ledger-mcp/handbook/cpa-handoff.md index a21a52a..7767713 100644 --- a/crates/ledger-mcp/handbook/cpa-handoff.md +++ b/crates/ledger-mcp/handbook/cpa-handoff.md @@ -3,3 +3,4 @@ - books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in (books served by `wanderbooks mcp`), then tell them where it is. The viewer's "Export for CPA" makes the same package. - Never file the package, or a file from it, into the books. - tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again. +- Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, the memos grouped); tell the human. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one. diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 5b4196c..69f1def 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -557,7 +557,7 @@ fn vendor_1099_report( year: i32, threshold: Option, ) -> Result { - ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, threshold) + ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, None, threshold) } impl BooksServer { @@ -5330,7 +5330,7 @@ impl BooksServer { #[tool( name = "books.export", title = "Export a report", - description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, and fixed_asset_register.", + description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of, period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, fixed_asset_register, and iif (default today).", annotations(read_only_hint = true, open_world_hint = false) )] async fn export(&self, params: Parameters) -> Result, String> { @@ -5343,8 +5343,30 @@ impl BooksServer { )); } let report = p.report.to_lowercase(); - if format == "iif" && report != "journal" { - return Err("iif format is only valid for the journal report".to_string()); + if format == "iif" { + if !matches!(report.as_str(), "journal" | "general_journal") { + return Err( + "iif format is only valid for the general_journal report (alias journal)" + .to_string(), + ); + } + if p.start.is_some() || p.end.is_some() { + return Err("iif takes as_of, not start or end: the file holds every posted entry from the first through as_of, so QuickBooks' balances match the books'".to_string()); + } + // As of today by default, like every other as-of export. + let as_of = match p.as_of.as_deref() { + Some(s) => parse_date(s)?, + None => today(), + }; + let iif = self.export_iif(as_of, &p.cash_accounts)?; + let warnings = iif.warnings(); + return Ok(Json(ExportOut { + format, + report, + content: iif.text, + content_encoding: None, + warnings, + })); } if format == "xlsx" || format == "pdf" { let bytes = if format == "xlsx" { @@ -5357,15 +5379,7 @@ impl BooksServer { report, content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes), content_encoding: Some("base64".to_string()), - })); - } - if format == "iif" { - let content = self.export_iif()?; - return Ok(Json(ExportOut { - format, - report, - content, - content_encoding: None, + warnings: Vec::new(), })); } let content = match report.as_str() { @@ -5392,6 +5406,7 @@ impl BooksServer { report, content, content_encoding: None, + warnings: Vec::new(), })) } @@ -5508,15 +5523,25 @@ impl BooksServer { ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string()) } - /// Render the journal as IIF text via ledger-import, declaring every - /// account with its QuickBooks type: 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). - fn export_iif(&self) -> Result { + /// The journal through `as_of` as IIF via ledger-package (the CPA + /// package's own general-journal.iif): the cash accounts (`cash`, else + /// the settings') BANK, the receivable and payable control accounts AR + /// and AP, the accounts of an asset bought by `as_of` FIXASSET, the + /// rest by their wanderbooks type; every posted entry from the first, + /// closes left out. + fn export_iif( + &self, + as_of: chrono::NaiveDate, + cash: &[String], + ) -> Result { let store = self.lock()?; - ledger_package::export_iif(store.ledger(), &self.settings().roles.cash) + let settings = self.settings(); + let cash = if cash.is_empty() { + settings.roles.cash.as_slice() + } else { + cash + }; + ledger_package::export_iif(store.ledger(), cash, as_of) } // ------------------------------------------------------------------ @@ -8073,7 +8098,7 @@ impl BooksServer { #[tool( name = "books.export_cpa_package", title = "CPA handoff package", - description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", + description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", annotations( read_only_hint = false, destructive_hint = false, @@ -8133,18 +8158,26 @@ impl BooksServer { tin_files::record(&vault_dir, &package.zip)?; } let tin_gaps = package.tin_gaps; - let note = match (p.include_tins, tin_gaps.len()) { + let mut note = match (p.include_tins, tin_gaps.len()) { (false, _) => "The package, with last fours only.".to_string(), (true, 0) => "The package, with full TINs, for the CPA.".to_string(), (true, n) => format!( "The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again." ), }; + let warnings = package.warnings; + if package.iif_changes > 0 { + note.push_str(&format!( + " {} account name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.", + package.iif_changes + )); + } let file = self.hand_over(&book, &package.file_name, "application/zip", package.zip)?; Ok(Json(ExportCpaPackageOut { file, tins_included: p.include_tins, tin_gaps, + warnings, note, })) } diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 38c7e4e..1eb9534 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -1682,7 +1682,8 @@ pub struct StaleRule { pub struct ExportParams { /// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are /// base64-encoded; PDF covers profit_and_loss, balance_sheet, and - /// cash_flow; IIF is only valid with report "journal". + /// cash_flow; IIF (plain ASCII text) is only valid with report + /// "general_journal" (alias "journal"). pub format: String, /// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or /// "cash_flow". @@ -1691,10 +1692,13 @@ pub struct ExportParams { pub start: Option, /// Period end for profit_and_loss and cash_flow, YYYY-MM-DD. pub end: Option, - /// As-of date for balance_sheet, YYYY-MM-DD. + /// As-of date for balance_sheet, trial_balance, fixed_asset_register, + /// and iif (every posted entry dated on or before it), YYYY-MM-DD. + /// Defaults to today. pub as_of: Option, - /// Cash account codes for cash_flow. Defaults to `[books] - /// cash_accounts`; one or the other is required for that report. + /// Cash account codes for cash_flow, and for iif its BANK accounts. + /// Defaults to `[books] cash_accounts`; one or the other is required + /// for cash_flow. #[serde(default)] pub cash_accounts: Vec, /// Liability account codes treated as financing for cash_flow. @@ -1721,6 +1725,16 @@ pub struct ExportOut { /// "base64" for XLSX and PDF, otherwise absent. #[serde(skip_serializing_if = "Option::is_none")] pub content_encoding: Option, + /// IIF: what the file writes differently from the books to fit + /// QuickBooks Desktop (plain ASCII; account names at most 31 + /// characters a level, numbers 7, memos 4,095; no two names or numbers + /// alike; a subaccount of another type than its parent's at the top + /// level): each account's name, number and place a line, and each thing written + /// differently in the memos a line naming the entries it is in + /// (`"—" is written "--" in the memos of entries 3, 5, 9`). No full + /// TIN. Tell the human. Absent when there are none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } // --------------------------------------------------------------------------- @@ -2668,6 +2682,11 @@ pub struct ExportCpaPackageOut { /// with books.set_tin or books.set_w9. #[serde(default)] pub tin_gaps: Vec, + /// What general-journal.iif writes differently from the books, as + /// `books.export` iif's `warnings` says it (the memos grouped); the + /// README lists them too. Absent when there are none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, /// What to tell the human. pub note: String, } diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 94adaca..002a0c1 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -1185,15 +1185,27 @@ fn mcp_import_export_over_stdio() { let chart = mcp.call( "books.import_chart_csv", - json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n"}), + json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n"}), ); - assert_eq!(chart["count"], 2); + assert_eq!(chart["count"], 3); + // The IIF is plain ASCII text with CR LF line endings, inline; what it + // writes differently from the books is in warnings (rules.md B-34). let iif = mcp.call( "books.export", json!({"format": "iif", "report": "journal"}), ); - assert!(iif["content"].as_str().unwrap().contains("!TRNS")); + let text = iif["content"].as_str().unwrap(); + assert!(text.contains("!TRNS") && text.is_ascii() && text.ends_with("\r\n")); + assert!( + text.contains("\r\nACCNT\tCafe Bei Jing\tEXP\t6010\r\n"), + "{text}" + ); + assert!(iif.get("content_encoding").is_none(), "{iif}"); + assert_eq!( + iif["warnings"], + json!(["account 6010's name \"Café 北京\" is written \"Cafe Bei Jing\""]) + ); let xlsx = mcp.call( "books.export", diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index dd18697..3e36f2f 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -1223,23 +1223,57 @@ fn xlsx_and_pdf_exports_are_real_files_with_the_books_in_them() { /// IIF: accounts declared with QuickBooks types per the documented mapping /// (cash accounts BANK, 1200 AR, 2000 AP, a registered asset's accounts /// FIXASSET, the rest by type), then one balanced TRNS/SPL/ENDTRNS block -/// per posted entry. Kills: export_iif's AR and AP comparisons. +/// per posted entry dated on or before as_of, numbered (DOCNUM) with its +/// entry id (rules.md B-33, B-34); plain ASCII text with CR LF, what it +/// writes differently (a line break and double quotes, the engine's own em +/// dashes) in warnings, grouped. Kills: export_iif's AR and AP comparisons. #[test] fn iif_declares_accounts_by_role_and_balances_every_transaction() { let dir = test_dir("iif"); let mut s = new_store(&dir); rich_books(&mut s); - let posted = s.ledger().posted_entries().count(); + let ledger = s.ledger().clone(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); - let iif = String::from_utf8(m.export(json!({"format": "iif", "report": "journal"}))).unwrap(); + let out = m.ok( + "books.export", + json!({"format": "iif", "report": "journal", "as_of": END}), + ); + assert!(out.get("content_encoding").is_none(), "plain text: {out}"); + let lunch = ledger + .posted_entries() + .find(|e| e.description().starts_with("Lunch")) + .unwrap() + .id(); + // The chart's `Meals, "client"`, its double quotes written single; + // then the memos, grouped by what is written differently: the lunch's + // double quotes and line break, and the em dashes of every + // depreciation memo in one line. + let months: Vec = ledger + .posted_entries() + .filter(|e| e.description().starts_with("Depreciation")) + .map(|e| e.id().to_string()) + .collect(); + assert_eq!(months.len(), 3); + let warnings = vec![ + "account 6010's name \"Meals, \\\"client\\\"\" is written \"Meals, 'client'\"".to_string(), + format!("\"\\\"\" is written \"'\" in the memo of entry {lunch}"), + format!("\"\\\"\\n\" is written \"' \" in the memo of entry {lunch}"), + format!( + "\"—\" is written \"--\" in the memos of entries {}", + months.join(", ") + ), + ]; + assert_eq!(out["warnings"], json!(warnings)); + let iif = out["content"].as_str().unwrap(); + assert!(iif.ends_with("\r\n") && !iif.replace("\r\n", "").contains(['\r', '\n'])); let mut types: HashMap = HashMap::new(); let mut header: Vec<&str> = Vec::new(); - let mut blocks = 0; + let mut docnums: Vec = Vec::new(); let mut sum = Decimal::ZERO; let mut in_trns = false; - for line in iif.lines() { + for line in iif.split("\r\n") { let f: Vec<&str> = line.split('\t').collect(); match f[0] { "!ACCNT" => header = f.clone(), @@ -1252,19 +1286,29 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() { assert!(!in_trns, "TRNS inside a transaction"); in_trns = true; sum = Decimal::ZERO; + docnums.push(f[7].parse().unwrap()); } + assert_eq!( + f[7], + docnums.last().unwrap().to_string(), + "DOCNUM on every line" + ); sum += dec(f[6]); } "ENDTRNS" => { assert!(in_trns); assert_eq!(sum, Decimal::ZERO, "an IIF transaction sums to zero"); in_trns = false; - blocks += 1; } _ => {} } } - assert_eq!(blocks, posted, "a transaction per posted entry"); + let through: Vec = ledger + .posted_entries() + .filter(|e| e.date() <= d(END)) + .map(|e| e.id()) + .collect(); + assert_eq!(docnums, through, "every posted entry through as_of, by id"); for (code, ty) in [ ("1000", "BANK"), ("1200", "AR"), @@ -1283,6 +1327,69 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() { "account {code}'s IIF type" ); } + // general_journal is the report's name; journal its alias (both work). + let alias = m.ok( + "books.export", + json!({"format": "iif", "report": "general_journal", "as_of": END}), + ); + assert_eq!(alias["content"], out["content"]); + // The file holds everything from the first entry: a range is refused, + // and so is another report. + let why = m.refuse( + "books.export", + json!({"format": "iif", "report": "journal", "start": START, "end": END}), + ); + assert!(why.contains("iif takes as_of, not start or end"), "{why}"); + let why = m.refuse( + "books.export", + json!({"format": "iif", "report": "trial_balance"}), + ); + assert!( + why.contains("only valid for the general_journal report"), + "{why}" + ); +} + +/// P3 review R5: books.export iif without as_of is as of today, like the +/// trial balance: an entry dated later is left out, and the two exports +/// made with no dates tie, account by account. +#[test] +fn iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance() { + let dir = test_dir("iif-today"); + let mut s = new_store(&dir); + rich_books(&mut s); + post( + &mut s, + "2099-01-04", + "Far future rent", + &[("6000", Dr, "1234.00"), ("1000", Cr, "1234.00")], + ); + drop(s); + let mut m = Mcp::start(&dir, "1000000.00"); + let iif = m.export_text(json!({"format": "iif", "report": "journal"})); + assert!(!iif.contains("Far future rent")); + let mut number: HashMap = HashMap::new(); + let mut iif_bal: BTreeMap = BTreeMap::new(); + for line in iif.split("\r\n") { + let f: Vec<&str> = line.split('\t').collect(); + match f[0] { + "ACCNT" => { + number.insert(f[1].to_string(), f[3].to_string()); + } + "TRNS" | "SPL" => *iif_bal.entry(number[f[4]].clone()).or_default() += dec(f[6]), + _ => {} + } + } + iif_bal.retain(|_, v| !v.is_zero()); + let tb = m.export_text(json!({"format": "csv", "report": "trial_balance"})); + let mut tb_bal: BTreeMap = BTreeMap::new(); + for row in csv_rows(&tb, &["code", "name", "debit", "credit"]) { + if row[0] != "TOTAL" { + tb_bal.insert(row[0].clone(), dec(&row[2]) - dec(&row[3])); + } + } + tb_bal.retain(|_, v| !v.is_zero()); + assert_eq!(iif_bal, tb_bal); } // ------------------------------------------------- P&L by class @@ -2767,7 +2874,9 @@ fn previews_move_balances_by_normal_side() { /// the receipts behind the year's posted entries (and only those), indexed /// with each entry's date, description, and debits; the company's /// documents but not loose receipts or withdrawn files; a README counting -/// the receipts; and a 1099 worksheet equal to `vendor_1099_report`. With +/// the receipts; a 1099 worksheet equal to `vendor_1099_report` through +/// as_of (a payment after it to the same contractor left out); and the +/// journal as IIF from the first entry through as_of. With /// include_receipts false, no evidence. Kills: export_cpa_package's /// receipt and document guards, the receipt count, the file-name cleaner. #[test] @@ -2775,6 +2884,8 @@ fn cpa_package_holds_the_year_and_its_evidence() { let dir = test_dir("cpa"); let mut s = new_store(&dir); let r = rich_books(&mut s); + // The payment after as_of went to the contractor too. + s.set_entry_vendor(r.after, Some(r.contractor)).unwrap(); // Two receipts on the rent, same-length names. let rent_a: &[u8] = b"rent receipt A"; let rent_b: &[u8] = b"rent receipt B"; @@ -2879,6 +2990,29 @@ fn cpa_package_holds_the_year_and_its_evidence() { let gj = xlsx_text(&files["general-journal.xlsx"]); assert!(gj.contains("Rent January") && !gj.contains("Prior-year sale")); assert!(!gj.contains("After mid-year"), "fiscal year to as_of only"); + // The IIF: from the first entry (the prior year's sale) through as_of. + let iif = std::str::from_utf8(&files["general-journal.iif"]).unwrap(); + assert!(iif.contains("\tPrior-year sale\r\n") && iif.contains("\tRent January\r\n")); + assert!(!iif.contains("After mid-year"), "through as_of only"); + assert!(iif.contains("\tLunch, 'team' quarterly\r\n")); + // The meals account's double quotes, the lunch's and its line break, + // and the em dashes of the depreciation memos: written differently, so + // said, the memos grouped (four lines for one name and four memos), in + // warnings and the README alike. + let warnings: Vec = out["warnings"] + .as_array() + .unwrap() + .iter() + .map(|w| w.as_str().unwrap().to_string()) + .collect(); + assert_eq!(warnings.len(), 4, "{out}"); + let readme = std::str::from_utf8(&files["README.txt"]).unwrap(); + for w in &warnings { + assert!(readme.contains(&format!("\n- {w}\n")), "{w} in {readme}"); + } + assert!(out["note"].as_str().unwrap().ends_with( + " 5 account name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human." + )); // Receipts: exactly the two behind the rent. let receipt_files: Vec<&str> = names @@ -2940,8 +3074,22 @@ fn cpa_package_holds_the_year_and_its_evidence() { std::str::from_utf8(&files["1099-worksheet-2025.csv"]).unwrap(), &["vendor", "form", "w9_on_file", "paid", "reportable"], ); - let want = ledger_core::vendor_1099_report(&ledger, 2025, None).unwrap(); + let want = ledger_core::vendor_1099_report_with_members( + &ledger, + 2025, + Some(d(END)), + None, + &ledger_core::PaymentAccounts::default(), + &[], + ) + .unwrap(); assert_eq!(w.len(), want.vendors.len()); + let contractor = w.iter().find(|row| row[0] == "Contractor").unwrap(); + assert_eq!( + amt(&contractor[3]), + dec("900.00"), + "the payment after as_of is left out" + ); for (row, v) in w.iter().zip(&want.vendors) { assert_eq!(row[0], v.name); assert_eq!(row[1], v.form.map(|f| f.label()).unwrap_or("")); diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 0cccaa4..2d897b6 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -1261,7 +1261,6 @@ fn happy_path(name: &str) -> Mcp { ("xlsx", "balance_sheet"), ("pdf", "profit_and_loss"), ("csv", "cash_flow"), - ("iif", "journal"), ("csv", "general_ledger"), ("json", "members_equity"), ("csv", "tax_summary"), @@ -1271,6 +1270,12 @@ fn happy_path(name: &str) -> Mcp { json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}), ); } + // The IIF takes as_of (every posted entry from the first through it), + // never a range. + m.ok( + "books.export", + json!({"format": "iif", "report": "general_journal", "as_of": "2026-12-31"}), + ); m.ok( "books.export_cpa_package", json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}), diff --git a/crates/ledger-package/Cargo.toml b/crates/ledger-package/Cargo.toml index e993f3f..b01a671 100644 --- a/crates/ledger-package/Cargo.toml +++ b/crates/ledger-package/Cargo.toml @@ -19,4 +19,6 @@ zip = "9.0.1" workspace = true [dev-dependencies] +proptest = "1" tempfile = "3" +toml = "1" diff --git a/crates/ledger-package/src/iif.rs b/crates/ledger-package/src/iif.rs index ef677f5..a69c5f8 100644 --- a/crates/ledger-package/src/iif.rs +++ b/crates/ledger-package/src/iif.rs @@ -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 { +/// 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 { use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif}; let fixed: std::collections::HashSet = 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 = 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()) } diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index 925ebb7..14ef10c 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -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, /// 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, + /// 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( 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( 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 = cash_codes .iter() .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) @@ -312,8 +342,9 @@ pub fn build_package( 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( "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( 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 = 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( } 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( zip: zip_buf, tin_gaps, receipt_count, + warnings, + iif_changes, }) } diff --git a/crates/ledger-package/src/vendor_1099.rs b/crates/ledger-package/src/vendor_1099.rs index e860a57..71fec6a 100644 --- a/crates/ledger-package/src/vendor_1099.rs +++ b/crates/ledger-package/src/vendor_1099.rs @@ -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, threshold: Option, ) -> Result { 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 { // 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() diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index f782126..44d2b15 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -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 { + 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 all_attachments(&self) -> Result, ledger_store::StoreError> { + PackageBooks::all_attachments(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) + } +} + +/// [`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| { + 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::>(), + 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, 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 = 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::>(), + [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::(), 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(); + } + 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 = 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}") + } + }; + 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); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 3bcda2c..da4cf17 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1858,8 +1858,15 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) { financing: vec![], }; tally( - ledger_core::form1099::vendor_1099_report_with_members(l, year, None, &sources, &[]) - .map(|_| ()), + ledger_core::form1099::vendor_1099_report_with_members( + l, + year, + None, + None, + &sources, + &[], + ) + .map(|_| ()), ); } let money = ledger_core::parties::money_accounts(l, &cash); diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index a5cf633..7499bba 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -1131,7 +1131,8 @@ posting. ## Tools — export - `books.export { format, report, start?, end?, as_of?, cash_accounts?, - financing_accounts?, investing_accounts? }` → `{ content, content_encoding? }`. + financing_accounts?, investing_accounts? }` → `{ content, + content_encoding?, warnings? }`. - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs `cash_accounts`), `trial_balance`, `members_equity` (statement of changes in members' equity; members from `wanderbooks.toml`; earnings @@ -1142,11 +1143,40 @@ posting. balance and split, ending balance), `general_journal` (alias `journal`: every entry, line by line, in date order). - Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`, - `csv`, `json` for every report; `iif` for the journal (QuickBooks - Desktop). + `csv`, `json` for every report; `iif` for `general_journal` (alias + `journal`), for QuickBooks Desktop. - Dates: period reports take `start` (default: first entry) and `end` - (default: today, local time); `balance_sheet` and `trial_balance` - take `as_of`. + (default: today, local time); `balance_sheet`, `trial_balance`, + `fixed_asset_register` and `iif` take `as_of` (default today). + - IIF (`docs/spec/rules.md` B-34; plan D-36): plain ASCII text, every + line ending CR LF, inline (no `content_encoding`): every posted entry + from the first through `as_of`, period-closing entries left out + (QuickBooks keeps none; imported, a close would zero its period's + profit), each transaction's DOCNUM its entry id; its BANK accounts are + `cash_accounts`, else the settings'. `start` or `end` is refused: the + file is cumulative, so QuickBooks' balances match the books'. A + character past ASCII is transliterated ("René" "Rene", "北京" "Bei + Jing", "—" "--"), a double quote is a single one (QuickBooks reads it + as a text delimiter), a tab or line break is a space, a control + character '?'. Within QuickBooks Desktop's limits (Intuit): an account + name level past 31 characters is cut there; accounts whose names would + then match get " (code)" (QuickBooks would merge them); a subaccount + whose QuickBooks type differs from its parent's is written at the top + level (QuickBooks gives a subaccount its parent's type); each parent + is declared before its subaccounts; an account number past 7 + characters, or written like another's, is left out of ACCNUM; a memo + past 4,095 characters is cut. No line names a customer or vendor (no + NAME column; see the package's README). `warnings` (absent when + there are none) says what the file writes differently from the books, + for the human: each account's name, number and place a line (`account 6010's + name "Café meals" is written "Cafe meals"`, `account number + "12345678" is left out: QuickBooks Desktop holds 7 characters at + most`); the memos grouped, each thing written differently once with + every entry it is in (`"—" is written "--" in the memos of entries 3, + 5, 9`; a run of changed characters is one thing), then the memos cut, + so a hundred memos with an em dash make one line. No line holds a full + TIN: a run of nine digits, or every numeral of a text holding one in + another script, is masked `•`. - Presentation (PDF and XLSX alike, QuickBooks-style): centered company, title, and period; a column-header row; "$" on subtotals and totals; negatives in parentheses with decimal points aligned; a rule above every @@ -1414,8 +1444,25 @@ entries. - `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, investing_accounts?, include_receipts?, include_tins? }` → `{ file, - tins_included, tin_gaps, note }`: the zip, `cpa-package-.zip`, - handed over as `file` (a `HandedFile`, "Tools — the TIN vault"). + tins_included, tin_gaps, warnings?, note }`: the zip, + `cpa-package-.zip`, handed over as `file` (a `HandedFile`, + "Tools — the TIN vault"). + **As of one day** (`docs/spec/rules.md` B-33): nothing in it reflects + anything dated after `as_of` (default today): no figure, entry, + receipt or worksheet row; the chart and `documents/` are as they stand. + One list of cash accounts and one of financing accounts serve the + whole package: the request's, else the settings'. + **QuickBooks file** (`general-journal.iif`, B-34): as `books.export` + iif makes it, through `as_of`; `warnings` says what it writes + differently from the books, in the same lines as `books.export` iif's + (account names and numbers one each, the memos grouped; every other + file keeps the books' text), the README lists the same lines (and + names the entries with a line on accounts receivable or payable, to + which QuickBooks Desktop may ask the CPA to add a customer or vendor), + and `note` ends "N account name(s), number(s), place(s) or memo(s) + are written differently in general-journal.iif to fit QuickBooks + Desktop (warnings says how; so does the README): tell the human.", N + counting each name, number, place and memo. **Receipts** (`include_receipts`, default true): `receipts/` holds every current file behind each posted entry of the fiscal year to date, filed on it or linked to it (`books.link_attachment`; a linked file counts as @@ -1453,8 +1500,11 @@ entries. party is the party with the member's name (not a display name) and the member role, as the viewer's capital statement finds it; with none, the member's TIN is a gap. - **1099 worksheet** with TINs adds `party_id, tin_last4, tin, tin_type, - tin_note`. A zip with the full set, each + **1099 worksheet** (`1099-worksheet-.csv`, `as_of`'s calendar + year): the payments from January 1 through `as_of` (the year to date; + the whole year for a December 31 package; DECISION D-36), and the + README says which. With TINs it adds `party_id, tin_last4, tin, + tin_type, tin_note`. A zip with the full set, each statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`, `cash-flow` (reconciled to `cash_accounts`), `members-equity` (multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 010269c..d1ba2b1 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -490,7 +490,8 @@ years after 2026, indexed to inflation and rounded to the nearest $100 Attorneys' gross proceeds stay at $600 (not modeled). What counts as paid: payments made in the calendar year (§6041(a)), so a -prepayment counts when made. The engine counts credits minus debits to the +prepayment counts when made. A worksheet as of a day (the CPA package's, +B-33) counts the year's payments from January 1 through that day. The engine counts credits minus debits to the payment sources on a vendor's tagged entries: `[books] cash_accounts` and `financing_accounts` (a member or lender paying on the company's behalf; the company is still the payer). A card charge leaves neither (1099-K); a @@ -680,7 +681,12 @@ with `include_tins` (DECISION, the owner, 2026-10-15: for the CPA, when asked): a refusal that quotes its input masks any TIN in it (`parties::redact_full_tins`, checked by `refusals_never_echo_a_tin`), and a party text field refuses nine digits however they are separated -(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `books.vendor_1099`'s +(`has_full_tin`: up to three spaces, hyphens, dots, slashes or +underscores between neighbors, in any script's digits; past ASCII every +Unicode space and dash and the fullwidth and small forms count, and +invisible characters such as a zero-width space or a right-to-left mark +don't count toward the three: `sources/unicode-tin-text.md`, Unicode +17.0). Files holding one (`books.get_w9`, `books.vendor_1099`'s `csv` with `include_tins`, and the CPA package, whose K-1 and 1099 worksheets carry every member's and payee's TIN by default) are never written to a path: no tool takes one (D-29, P2 slice 6). Each is handed @@ -1015,3 +1021,170 @@ checked to change no figure or entry; `approval_rules_off_by_default`, (`exports_round_trip`) and codec unit tests; `ledger-mcp` `tests/spec_kept.rs`; Lean `Step.record_changes_no_figure`, `Reachable.records`; fuzz `fuzz_settings_replay`. + +## B-33 A package as of a day shows the books through that day + +**Status:** CONVENTION (a report as of a day reports the books on that +day: balances through it, a period's activity from the period's start +through it; nearest authorities the accounting period, IRS Pub. 538, +`sources/irs-periods-and-methods.md`, and the information returns' +calendar year, `sources/irs-1099-thresholds.md`); DECISION (owner, +2026-10-20, plan D-36: the 1099 worksheet stops at as_of too) + +Nothing in the CPA package made as of a day (`books.export_cpa_package`, +the viewer's Export for CPA) reflects anything dated after it: no figure, +entry, receipt or worksheet row. The statements and the ledgers cover +the fiscal year containing as_of, through it; the balance sheet, the +trial balance and the fixed asset register are as of it (the register +only when an asset was bought by then); receipts/ holds the evidence of +the year's entries through it; the members' equity statement and the +K-1 worksheet, the year to date; general-journal.iif, every posted entry +from the first through it (B-34). The 1099 worksheet counts the payments +of as_of's calendar year from January 1 through as_of: information +returns add up what is paid "in any calendar year" (26 U.S.C. §6041(a); +"during such calendar year", §6041A(a)(2)), so a worksheet as of June 30 +is the year to date, and a vendor not yet at the threshold is not yet +reportable; a December 31 package counts the whole year, as before. The +chart of accounts and documents/ are as they stand when the package is +made. One list of cash accounts and one of financing accounts serve the +whole package (the request's, else the settings'): the cash flow, the +K-1 worksheet, the IIF's bank accounts and the 1099 payments read the +same. + +**Enforced:** `ledger_package::build_package` (INVARIANTS 1, 6, 7), +`ledger_package::export_iif`, `ledger_package::vendor_1099_report` and +`ledger_core::vendor_1099_report_with_members` (`through`). +**Checked:** `ledger-package` `tests/package.rs` +(`every_file_in_an_as_of_package_stops_at_as_of`: books the same through +as_of, with and without activity after it (a 1099 payment with its +receipt, an asset bought, a reversal of an entry dated as_of, the +members' contributions, an earlier asset sold), make the same package +byte for byte, every file and which files there are, with and without a +fixed asset register, mid-year and at year end; `as_of_package_iif_stops_at_as_of`, +`the_iif_through_as_of_at_its_edges`, +`the_1099_worksheet_stops_at_as_of`, +`one_list_of_cash_accounts_per_package`; the property +`iif_ties_to_the_trial_balance_as_of`, 2,000 cases); `ledger-mcp` +`oracle_mcp.rs` (`cpa_package_holds_the_year_and_its_evidence`); the +bookspec examples `bookspec/examples/as-of-package-stops-at-as-of.toml` +and `bookspec/examples/1099-worksheet-through-as-of.toml`. + +## B-34 The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out + +**Status:** AUTHORITY (Intuit, `sources/intuit-iif.md`: IIF files are +ASCII text, tab-separated, with no escape; an account is known by its +NAME, a subaccount's NAME its path joined by colons; lists come before +what uses them; DOCNUM is "the transaction's number"; the kit's 2007+ +example file ends every line CR LF; QuickBooks Desktop holds an account +name of 31 characters, an account number of 7 and a memo of 4,095; "The +parent and subaccount must be the same account type"; a journal entry +to accounts receivable or payable needs a customer or vendor, and takes +one such account a transaction); CONVENTION (a QuickBooks integrator and +an IIF importer, same file: a longer name is cut on a chart import but not +in a journal's lines; a double quote is a text delimiter); DECISION (owner, 2026-10-20, plan D-36: plain ASCII, what isn't +ASCII transliterated and listed; CR LF; DOCNUM the entry's id; closing +entries left out; every entry since the company began until the CPA +says otherwise); Unicode Standard Annex #14 (the mandatory line breaks) + +1. **One transaction per posted entry dated on or before as_of**, from + the first, in the order posted, **except period-closing entries**: + QuickBooks keeps no closing entries (it works out retained earnings + itself; its "closing date" only locks a period), 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, and its income and expenses + each period's profit and loss; the members' shares are in the + package's members' equity statement and K-1 worksheet. + `books.export` iif takes as_of (default today), never a range. + Whether the file should hold only the fiscal year instead goes to the + CPA before the 2027 package (D-36); for the first year the two are + the same. +2. **DOCNUM is the entry's id** in the books, on the TRNS and every SPL, + so the CPA can trace a QuickBooks transaction to its entry and its + receipts. +3. **The file is ASCII and every line ends CR LF.** In a field (a NAME, + an ACCNUM, a MEMO) a tab or a mandatory line break (LF, VT, FF, CR, + NEL, U+2028, U+2029) is a space; any other control character is '?'; + printable ASCII is itself, but a double quote, which is a single one + (QuickBooks reads a double quote as a text delimiter); any other + character is its transliteration by deunicode 1.6.0, pinned exactly + ("René" "Rene", "北京" "Bei Jing", "—" "--", "“" "'", "😀" "grinning"; + a transliterated word's trailing space dropped at the end or before a + space), or '?' when it has none. Every other file in the package keeps + the books' text (the PDFs carry Inter and Noto fallbacks for Chinese, + Japanese, Korean, Arabic, Hebrew, Thai and Devanagari, Arabic and + Hebrew set right to left; no emoji font, so an emoji prints as an + empty box). +4. **An account's own name**, so written, has each ':' replaced by '-' + (a fullwidth colon too), is trimmed ('?' if nothing is left) and cut + to 31 characters (QuickBooks Desktop's limit; cut by QuickBooks, the + ACCNT line and the transactions' would no longer match); its NAME is + its parents' NAMEs and its own, joined by ':'. A subaccount whose + QuickBooks type differs from its parent's (a bank account under a + header that is no bank, an asset's cost under a "Fixed assets" header) + is written at the top level, its own subaccounts with it: QuickBooks + gives a subaccount its parent's type, and the type decides where its + balance shows. Each parent is declared before its subaccounts, the + accounts otherwise in the order of their codes. Accounts whose NAMEs + would match ignoring case, which QuickBooks would merge, get + " (code)" after their own names (the name cut to make room, the code + written like a name, "no number" for a blank one), level by level from + the top and again until no two match; a match left after that (two + codes written alike, ignoring case, or alike in their first 30 + characters) is refused. +5. **ACCNUM is the account's code**, written as a field and trimmed, or + left blank (ACCNUM is optional; the NAME identifies the account) when + it is longer than 7 characters (QuickBooks Desktop's limit) or another + account's would be written the same, ignoring case. **A MEMO** is cut + to 4,095 characters (QuickBooks Desktop's limit). +6. **Nothing changes silently.** Each account name, number or place and + each memo the file writes differently from the books (`Iif::changes`) + is said, both texts quoted and escaped, one line each, in the + `warnings` of `books.export` and `books.export_cpa_package` and in the + package README (the viewer's note counts them): each account's name, + number and place a line; the memos grouped by what is written + differently, each run of changed characters once with every entry it + is in (`"—" is written "--" in the memos of entries 3, 5, 9`), then + the memos cut. No line holds a full TIN (B-26): a run of nine digits, + in any script's digits and separators, is masked, and so is every + numeral of a text that holds one once written as a field (digits a + tab apart, letters deunicode writes as digits); a memo line whose + digits are masked says so. +7. **No customer or vendor NAME.** Intuit's IIF help makes NAME + optional and states no need of it in a journal; QuickBooks Desktop + asks for a customer or vendor on a journal entry to accounts + receivable or payable, and takes one A/R or A/P account a transaction + (Intuit staff and help, `sources/intuit-iif.md`). Writing names would + need the customer and vendor lists declared as well, with names unique + across them. So the file names none, and the package README names the + entries with a line on an A/R or A/P account (`Iif::receivable_payable`), + so the CPA can add the name if QuickBooks asks. + +**Enforced:** `ledger_import::iif` (`field`, `iif_accounts`, +`journal_to_iif`, `Iif::warnings`), `ledger_package::export_iif` and +`build_package`, `ledger-mcp` `books.export` and +`books.export_cpa_package`. +**Checked:** `ledger-import` `iif.rs` unit tests (every one of the +1,112,064 Unicode scalar values written as printable ASCII, alone and +beside a letter, and kept by a second pass; the transliteration table +pinned); `tests/spec_import.rs` (`iif_docnum_is_the_entry_id`, +`iif_writes_plain_ascii_and_lists_every_change`, +`iif_every_line_ends_cr_lf`, `iif_distinct_accounts_never_share_a_name`, +`iif_a_subaccount_of_another_type_stands_at_the_top_level`, +`iif_declares_each_parent_before_its_subaccounts`, +`iif_fits_quickbooks_desktop_limits`, +`iif_warnings_group_the_memos_by_what_is_written_differently`, +`iif_warnings_never_hold_a_tin`, +`iif_change_warnings_are_one_line_each`, +`iif_keeps_a_tin_readable_to_the_package_scan`, the properties +`iif_round_trips_random_books` and +`iif_names_never_clash_and_every_change_is_listed`); `ledger-package` +`tests/package.rs` (`iif_leaves_out_period_closes`, +`the_readme_lists_what_the_iif_writes_differently`, +`the_readme_names_the_entries_on_receivables_and_payables`, +`the_bookspec_iif_example_matches_the_engine`, and B-33's); the bookspec +example `bookspec/examples/iif-quickbooks-journal-file.toml` (formats +appendix: the file line by line and its changes, derived by hand); +`ledger-mcp` `oracle_mcp.rs` +(`iif_declares_accounts_by_role_and_balances_every_transaction`, +`iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance`), +`mcp_stdio.rs` (`mcp_import_export_over_stdio`). diff --git a/docs/spec/sources/intuit-iif.md b/docs/spec/sources/intuit-iif.md index dc9e8ec..5fdea22 100644 --- a/docs/spec/sources/intuit-iif.md +++ b/docs/spec/sources/intuit-iif.md @@ -107,12 +107,84 @@ SHA-256 of the retrieved file (`journal.zip`): `4f578d227810f5bfadde142cdab9f941 Cited (not quoted): its read-me says list declarations (accounts, classes, customers, vendors, other names) come first so names land on the correct lists, and that header columns must be present even when left blank. Its `!ACCNT` rows show a subaccount written `Construction:Labor`. Its address fields that contain a comma are wrapped in double quotes (`"Anywhere, AZ 85740"`), as a spreadsheet writes tab-delimited text; the kit does not say whether QuickBooks strips those quotes. +## Character limitations for fields in QuickBooks (US, updated 8/3/2026) +Source: +Retrieved: 2026-10-20 +SHA-256: not available (read through a fetch tool; a direct download was refused by the server) + +Tagged for QuickBooks Desktop (Mac Plus, Premier Plus, Pro Plus). Its one sentence of text: + +> Each field in your company data file has a maximum number of characters that can be entered. + +Cited (not quoted), from its table: Account Name 31; Account Numbers 7; Memo 4,095 (of which printed checks show about 30 to 60 and the Transaction Detail report 96); invoice number 11; customer, vendor and other names 41. It has no row for a journal entry's number, and says nothing of what QuickBooks does with longer text, on import or otherwise. + +The same article for the UK (`.../en-uk/help-article/printing-preferences/character-limitations-fields-quickbooks/L7eIy5gE3_GB_en_GB`, retrieved 2026-10-20) gives Account Name 31 but Account Numbers 25: a different product. The books are a US company's, so the US figures hold. + +## Intuit's .NET SDK reference: `Account.Name` +Source: +Retrieved: 2026-10-20 + +Cited (not quoted): "User recognizable name for the Account", with ValidRange "QBW: Max=31" (QuickBooks Desktop; QuickBooks Online: 100). Its `Account.AcctNum` page (`.../95a0835b-80bd-4fe1-2d7f-96ea2c372442.htm`) states no length. + +So the limits are Intuit's own: an account name (one level of a subaccount's NAME, as the account's own name is entered) at most 31 characters, an account number at most 7, a memo at most 4,095. The books write each within them: a longer level is cut to 31 (then no two NAMEs may match), a longer number is left out of ACCNUM (it is optional; the NAME identifies the account), a longer memo is cut to 4,095, and every one is listed (rules.md B-34). + +## Add, edit, or delete accounts in QuickBooks Desktop (US, updated 8/4/2026) +Source: +Retrieved: 2026-10-20 +SHA-256: not available (read through a fetch tool) + +Tagged for QuickBooks Desktop (Mac Plus, Premier Plus, Pro Plus, Accountant, Enterprise). Under "Add a subaccount", a note: + +> The parent and subaccount must be the same account type. + +So the books write a subaccount whose QuickBooks type differs from its parent's (a bank account under a header that is no bank, an asset's cost account under a "Fixed assets" header) at the top level, its own subaccounts with it, and list it (rules.md B-34). Intuit's own journal sample (`journal.zip`, above) declares `Construction:Labor` without ever declaring `Construction`, so QuickBooks makes a missing parent itself; the books declare each parent before its subaccounts all the same, so a parent keeps its own type and number rather than taking ones QuickBooks guesses. + +## Customer and vendor names on accounts receivable and payable + +Searched 2026-10-20. What Intuit says: + +- The IIF Import Kit's header help (`!TRNS.htm`, `!SPL.htm`, above) marks TRNSTYPE, ACCNT and AMOUNT required; NAME is "the name of the customer, vendor, payee, or employee", not marked required, with no word on accounts receivable or payable. The kit's General Journal example and the journal sample have no NAME column at all. +- The journal sample's read-me (`journal.zip`, `General Journal IIF file read me.txt`, SHA-256 `2e45b0f7a15d78f487d510547084d401d99bda02630d10747e37e00ca56f2c52`): "A customer name attached to the spl line of a General Journal transaction will import to the vendor list and the vendor name attached to the trns line of a General Journal transaction will import to the other names list unless the names are already on the correct lists." So a NAME written without declaring the customer and vendor lists first lands on the wrong list. +- Intuit's QuickBooks Team, answering "Can I post a journal entry to Accounts Receivable without specifying a customer name?" (Maybelle_S, QuickBooks Team badge, 12 January 2024), (retrieved 2026-10-20), about QuickBooks Desktop: + + > Accounts Receivable (AR) or Accounts Payable (AP) requires a customer or vendor name assignment. + +- "Troubleshoot ... Warning: You Cannot Use More Than One" (US, updated 8/5/2026; QuickBooks Desktop Mac Plus, Premier Plus, Pro Plus), (retrieved 2026-10-20): QuickBooks will not record a transaction with two A/R accounts, two A/P accounts, or one of each; its message: + + > Warning: You cannot use more than one A/R or A/P account in the same transaction. + +Not Intuit, for the record: a third-party IIF vendor answering on Intuit's community (BigRedConsulting, 14 April 2020, ) wrote that "a NAME is required for any AR row", about a bill, not a journal; QuickBooks Desktop sync tools quote the refusal "Transaction split lines to accounts receivable must include a customer on that split line" (Acctivate, ). + +No Intuit source says what an IIF journal import does with an A/R or A/P line that has no NAME (refuses it, imports it, or asks). So the books write no NAME (writing names would need the customer and vendor lists declared too, with a name unique across them) and the package README names the entries with an A/R or A/P line, so the CPA can add the name in QuickBooks if it asks (rules.md B-34 rule 7). + +## What QuickBooks does with an over-long name (not Intuit) +Source: Thomson Reuters, Accounting CS help, "Export payroll journal entries", +Retrieved: 2026-10-20 + +Cited (not quoted), a QuickBooks integrator's documentation, not Intuit's: QuickBooks limits an account's name to 31 characters; importing a chart of accounts IIF file cuts a longer name to 31, but importing a journal IIF file does not, so the journal's accounts no longer match and QuickBooks creates a new (duplicate) bank account for each it doesn't recognize. It advises names of 31 characters or fewer. This is why the books cut each level themselves: the ACCNT line and every TRNS and SPL line then carry the same name. + +## Double quotes in an IIF field (not Intuit) +Intuit's own journal sample (above) wraps fields holding a comma in double quotes, as a spreadsheet writes tab-delimited text, so QuickBooks' reader treats a double quote as more than a character. Two non-Intuit sources say so outright: + +- Reckon, "Importing the IIF file in Reckon Accounts Business, changes the Inch “ symbol to Feet ‘ symbol", (retrieved 2026-10-20, no date given). Cited (not quoted): Reckon Accounts Business, which imports IIF files, changes each double quote to a single one by design, because many file formats treat the double quote as a special character. +- Jonathan Clark, interchange-users mailing list, 28 July 2003, : + + > From what we can tell, QB uses double quotation marks (") as text delimiters when there are certain characters in the field. + + > For imports, you will break the import if you have double quotes within your text + +So the books write a double quote as a single one, listed like any other change (rules.md B-34). + ## Not found in any Intuit source -Searched 2026-10-13 (the help articles above and every document in the IIF Import Kit and journal sample); none states: +Searched 2026-10-13 and 2026-10-20 (the help articles above and every document in the IIF Import Kit and journal sample); none states: -- an escaping mechanism for tabs, line breaks, or double quotes inside a field (no escape syntax is documented; the only safe reading is that fields must not contain tabs or line breaks); +- an escaping mechanism for tabs, line breaks, or double quotes inside a field (no escape syntax is documented; the only safe reading is that fields must not contain tabs or line breaks, and, from the reports above, double quotes); - an explicit sentence that a transaction's TRNS and SPL amounts must sum to zero (every example does; the sign rule implies it); - the allowed number of decimal places, or whether thousands separators or currency symbols are accepted in AMOUNT; -- the character encoding beyond "ASCII text"; -- whether a four-digit year is accepted everywhere despite the "MM/DD/YY" wording (Intuit's 2007+ example uses one). +- the character encoding beyond "ASCII text" (so the books write plain ASCII, transliterating anything else and listing what changed: rules.md B-34, DECISION D-36); +- whether a four-digit year is accepted everywhere despite the "MM/DD/YY" wording (Intuit's 2007+ example uses one); +- what QuickBooks Desktop does on import with an account name, account number or memo longer than its limits (only the non-Intuit note above, for names), how many levels of subaccounts it allows, or a limit on a whole NAME with its parents; +- how long a journal entry's number (DOCNUM) may be (the limits article has no row for it; the books' entry ids are a few digits); +- whether QuickBooks matches account names or numbers ignoring case (the books keep no two alike even ignoring case); +- what an IIF journal import does with a line on accounts receivable or payable that names no customer or vendor (above). diff --git a/docs/spec/sources/unicode-tin-text.md b/docs/spec/sources/unicode-tin-text.md new file mode 100644 index 0000000..ce55476 --- /dev/null +++ b/docs/spec/sources/unicode-tin-text.md @@ -0,0 +1,65 @@ +# Unicode character properties: how a TIN can be written + +Governs `ledger_core::parties::has_full_tin` (rules.md B-26): which +characters separate a TIN's digits, and which are invisible between them. +The Unicode Character Database is © Unicode, Inc., under the Unicode +License v3, which permits copying the data files; the code point lists +below are transcribed from them (facts, not prose). + +## PropList.txt, Unicode 17.0.0 (2025-06-30) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file: `130dcddcaadaf071008bdfce1e7743e04fdfbc910886f017d9f9ac931d8c64dd` + +**Dash** (every dash and hyphen): U+002D, U+058A ARMENIAN HYPHEN, U+05BE +HEBREW PUNCTUATION MAQAF, U+1400, U+1806, U+2010..U+2015 (HYPHEN, +NON-BREAKING HYPHEN, FIGURE DASH, EN DASH, EM DASH, HORIZONTAL BAR), +U+2053 SWUNG DASH, U+207B SUPERSCRIPT MINUS, U+208B SUBSCRIPT MINUS, +U+2212 MINUS SIGN, U+2E17, U+2E1A, U+2E3A..U+2E3B, U+2E40, U+2E5D, +U+301C WAVE DASH, U+3030 WAVY DASH, U+30A0, U+FE31..U+FE32, U+FE58 SMALL +EM DASH, U+FE63 SMALL HYPHEN-MINUS, U+FF0D FULLWIDTH HYPHEN-MINUS, +U+10D6E, U+10EAD. + +**White_Space**: U+0009..U+000D, U+0020, U+0085, U+00A0, U+1680, +U+2000..U+200A, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR, +U+202F, U+205F, U+3000 IDEOGRAPHIC SPACE. Less the controls and the two +line and paragraph separators, these are the space separators (general +category Zs). Rust's `char::is_whitespace` is this property. + +## DerivedCoreProperties.txt, Unicode 17.0.0 +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file: `24c7fed1195c482faaefd5c1e7eb821c5ee1fb6de07ecdbaa64b56a99da22c08` + +**Default_Ignorable_Code_Point** (drawn as nothing when not supported): +U+00AD SOFT HYPHEN, U+034F, U+061C ARABIC LETTER MARK, U+115F..U+1160, +U+17B4..U+17B5, U+180B..U+180F, U+200B..U+200F (ZERO WIDTH SPACE, the +joiners, LEFT-TO-RIGHT and RIGHT-TO-LEFT MARK), U+202A..U+202E, +U+2060..U+206F (WORD JOINER, the invisible operators, the directional +isolates), U+3164, U+FE00..U+FE0F (variation selectors), U+FEFF ZERO +WIDTH NO-BREAK SPACE, U+FFA0, U+FFF0..U+FFF8, U+1BCA0..U+1BCA3, +U+1D173..U+1D17A, U+E0000..U+E0FFF (tags, variation selectors +supplement). + +## Compatibility decompositions (UnicodeData.txt) +Derived 2026-10-20 with Python's `unicodedata` (Unicode 16.0.0; these +mappings are unchanged in 17.0): the characters past ASCII whose NFKC +form is one of the ASCII separators. + +- `' '`: U+00A0, U+2000..U+200A, U+202F, U+205F, U+3000 (all Zs). +- `'-'`: U+FE63, U+FF0D (both Dash). +- `'.'`: U+2024 ONE DOT LEADER, U+FE52 SMALL FULL STOP, U+FF0E FULLWIDTH + FULL STOP. +- `'/'`: U+FF0F FULLWIDTH SOLIDUS. +- `'_'`: U+FE33, U+FE34, U+FE4D..U+FE4F, U+FF3F FULLWIDTH LOW LINE. + +## How the books read them (rules.md B-26) + +A TIN is nine numerals (any script's: `char::is_numeric`) with at most +three separators between neighbors. In ASCII the separators are exactly +the space, hyphen, dot, slash and underscore (a tab or line break is +not one). Past ASCII they are every Zs space, every Dash character, and +the compatibility forms of the dot, slash and underscore above; the line +and paragraph separators and NEL are line breaks, as in ASCII, and end a +run. Default-ignorable characters are invisible, so any number of them +may sit between two digits without counting toward the three. diff --git a/docs/testing.md b/docs/testing.md index 7464c9a..5c22be7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -700,6 +700,201 @@ fields; `Currency::allocation_places`. P3's accept counts `ledger-core` and `ledger-import` (R-6); the store and MCP mutants above are for the whole pass. +## P3: the IIF and as-of (2026-10-20) + +Defects 7 and 21 (plan P3; the owner's decisions D-36). Rules +`docs/spec/rules.md` B-33 (a package as of a day) and B-34 (the IIF). No +mutation run (D-33): what P12 must cover is at the end. + +**Named regression tests**, each from its reproduction, on anonymized +fixtures: +- Defect 7, DOCNUM: `ledger-import` `tests/spec_import.rs` + `iif_docnum_is_the_entry_id` (a draft never posted, three entries + posted out of id and date order: DOCNUM 3, 2, 4 on the TRNS and every + SPL; the file had written 1, 2, 3). +- Defect 7, the characters (D-36, plain ASCII): + `iif_writes_plain_ascii_and_lists_every_change` (accented letters, + Chinese, emoji, a fullwidth colon and fullwidth digits, a tab in a + name, controls, a decomposed accent, private use: each written form + exact, every change listed exactly), `iif_every_line_ends_cr_lf`, + `iif_change_warnings_are_one_line_each`, + `iif_keeps_a_tin_readable_to_the_package_scan` (an en-dash or + no-break-space TIN in a memo stays a TIN to the package's TIN scan), + `iif_distinct_accounts_never_share_a_name` (review R2: duplicate names, + by case, siblings, a fullwidth colon against a hyphen, two private-use + names; a name that is another's tagged form tagged in turn; the clash + left when two codes are written alike refused), + `iif_fits_quickbooks_desktop_limits` (Intuit's limits, found in the P3 + review: a level past 31 characters cut, two alike once cut tagged, a + subaccount under its cut parent; an account number past 7 characters + and two written alike left out of ACCNUM; a memo past 4,095 cut; every + line of the warnings exact; a hand-built level past 31 refused), + `iif_warnings_group_the_memos_by_what_is_written_differently` (the + lead's call: 100 depreciation memos with an em dash make one line), + `iif_warnings_never_hold_a_tin` (a TIN in a memo or an account name, + in ASCII or fullwidth digits, never in a warning), + `iif_field_transliteration_exact` and `iif_paths_and_transliteration` + (the old expectations, "Caf? meals" among them, rewritten to the rule). +- Defect 21, the package's IIF: `ledger-package` `tests/package.rs` + `as_of_package_iif_stops_at_as_of` (next year's rent was in the file; + now the four entries of the year, by id, tied to the trial balance), + `the_iif_through_as_of_at_its_edges` (an entry on as_of in, the day + after out, a reversal after as_of leaving its original, as of a day + before the first entry: the accounts and no transaction). +- Defect 21, every file: `every_file_in_an_as_of_package_stops_at_as_of` + (review R4: books the same through as_of, one set with a 1099 payment, + its receipt and an asset bought the day after, a reversal of the entry + dated as_of, the members' contributions and an earlier desk sold, build + the same package byte for byte, every file and which files there are, + PDFs included, with and without TINs and receipts, with and without a + fixed asset register, mid-year and at year end; a package days later + sees the activity). Undoing any one of the as-of + bounds (the IIF's entries, its FIXASSET typing, the 1099 worksheet's + `through`, the fixed asset register's presence) fails it; checked by + hand. +- D-36, the 1099 worksheet: `the_1099_worksheet_stops_at_as_of` (a + payment on as_of counts, the day after doesn't; a vendor under the + threshold so far is not yet reportable; a year-end package and the + tools' worksheet unchanged). +- D-36, closes: `iif_leaves_out_period_closes` (two months closed: no + close written, income and expenses equal the year's profit and loss, + balances the trial balance without the closes). +- Plan P3's accept, a bookspec example per regression test: the as-of + ones (`bookspec/examples/as-of-package-stops-at-as-of.toml`, + `1099-worksheet-through-as-of.toml`) and the IIF's + (`iif-quickbooks-journal-file.toml`, formats appendix), each expected + value derived by hand; `the_bookspec_iif_example_matches_the_engine` + reads the IIF example and checks the engine gives exactly its file and + changes. +- Review R18 and the README: `one_list_of_cash_accounts_per_package`, + `the_readme_lists_what_the_iif_writes_differently` (the memos grouped: + six depreciation memos and a lunch with an em dash, one line). + +**Exhaustive and property tests.** `iif.rs`'s unit tests write every +one of the 1,112,064 Unicode scalar values (alone, after a letter, before +one) and check the field is printable ASCII without a double quote and a +fixed point, its changed runs empty exactly when it is unchanged, and a +level never empty, colon-free, trimmed and at most 31 characters (a +complete check over the finite alphabet; review R15); `with_tag`'s and +`changed_runs`' edges by hand; `the_transliteration_table_is_pinned` +samples deunicode 1.6.0, pinned exactly in `Cargo.toml` (R14). +Properties: `iif_round_trips_random_books` (memos mixing ASCII, Latin-1, +dashes and quotes, every kind of line break, controls, CJK, emoji, +combining marks, private use; DOCNUM, CR LF, the changes list exact), +`iif_names_never_clash_and_every_change_is_listed` (random charts from a +pool of clashing names, long names alike once cut, codes with colons, +past 7 characters, and one alike another once trimmed; every warning +line checked against the books and the file), `iif_ties_to_the_trial_balance_as_of` (2,000 +cases: random books with reversals and closed months, random as_of; the +file's entries and balances against the trial balance without the +closes). + +**End to end.** `ledger-mcp` `oracle_mcp.rs`: +`iif_declares_accounts_by_role_and_balances_every_transaction` (as_of, +DOCNUM the ids, the warnings exact: the fixture's line-broken memo and +the engine's own "Depreciation — Laptop — 2025-03" memos, the alias, a +range refused), `iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance` +(review R5), `cpa_package_holds_the_year_and_its_evidence` (the IIF from +the prior year's sale through as_of; a payment after as_of to the +contractor left out of the 1099 worksheet; the note's count); +`mcp_stdio.rs` (a Chinese account name over stdio: ASCII text, CR LF, +`warnings`); `spec_mcp.rs` (iif with as_of); `ledger-gui` +`tests/exports.rs` (the README's IIF line names the as_of). + +**Names in every script, in the PDFs and the viewer.** Inter has no +Chinese, Japanese, Korean, Arabic, Hebrew, Thai or Devanagari glyphs: +every such character printed as Inter's empty box (glyph 0), and the +viewer drew boxes too. `ledger-import` `src/fonts.rs` now bundles the +fallbacks, each once: a subset of Noto Sans CJK SC +(`fonts/NotoSansCJK-LICENSE.txt` says how it was made) and Noto Sans +Arabic UI, Hebrew, Thai and Devanagari, unmodified +(`fonts/NotoSans-LICENSE.txt`: versions, upstream commits, SHA-256; 412,968 +bytes for the four). Arabic is the UI form because Noto Sans Arabic 2.013 +draws a letter's dots as marks of their own, which Typst writes as text +runs of their own: its PDFs' text read "شلام" for "سلام", with letters +doubled. No emoji font (about 10 MB): an emoji prints as an empty box in +the PDFs. Tests: `fonts.rs` +`every_template_names_every_fallback_in_order`; `pdf.rs` +`chinese_japanese_and_korean_print_as_written` and +`arabic_hebrew_thai_and_devanagari_print_as_written` (every glyph from +its script's font, none glyph 0); `spec_import.rs` +`pdf_keeps_chinese_japanese_and_korean_names` (the ToUnicode map) and +`pdf_prints_every_bundled_script_as_written` (the PDF read back with +hayro, the viewer's own reader: Arabic and Hebrew read right to left give +the words, the lam-alef ligature included; Thai, Devanagari and Chinese +left to right; every font embedded; failed with Noto Sans Arabic 2.013). +The viewer registers the same `FALLBACKS` after its own fonts in every +family (`ledger-gui` `theme.rs` `every_family_falls_back_to_the_noto_fonts`: +the very bytes, by pointer, and a real glyph for each script's letters in +every family) and `tests/scripts.rs` +`account_names_in_other_scripts_show_as_written` (the Accounts page paints +each name; with `SHOT_DIR` set, it saves `scripts.png`: looked at, +2026-10-20). egui shapes and joins each word, right to left for Arabic +and Hebrew, but doesn't reorder the words of a right-to-left phrase: +"مكتب الرياض" shows its two words left to right. + +**TINs in any script (B-26).** `ledger_core::parties::has_full_tin` +missed "123-45-6789": the fullwidth hyphen-minus, the ideographic +and thin spaces, the Hebrew maqaf and the other Unicode dashes weren't +separators, and a zero-width space or a right-to-left mark between digits +ended a run. Past ASCII every Zs space, every Dash character and the +compatibility forms of the dot, slash and underscore now separate, and +default-ignorable characters don't count (`docs/spec/sources/unicode-tin-text.md`, +Unicode 17.0); ASCII reads as before. `spec_parties.rs` +`has_full_tin_reads_every_script_and_separator` and the property +`has_full_tin_keeps_its_ascii_reading` (2,000 cases: on ASCII the same +answer as the old reading, kept in the test; elsewhere it finds at least +what the old one did). + +**Second review (2026-10-20).** QuickBooks gives a subaccount its +parent's type (Intuit): `iif_a_subaccount_of_another_type_stands_at_the_top_level` +(a bank account under a header that is no bank, an asset's cost under +"Fixed assets": each written at the top level, keeping its type, listed, +its own subaccount with it, a name it then shares told apart by code); +`check_iif` now checks every subaccount's type is its parent's and every +parent is declared before it; `iif_declares_each_parent_before_its_subaccounts`. +The file names no customer or vendor (Intuit states no need for an IIF +journal; QuickBooks Desktop asks for one on A/R or A/P): +`the_readme_names_the_entries_on_receivables_and_payables`. A memo line +whose digits are masked says so (`iif_warnings_never_hold_a_tin`, which +now also holds a name and a memo that are a TIN only once written, digits +a tab apart: with `has_full_tin` reading every script, nothing else +tested the masks' second pass). `iif_fits_quickbooks_desktop_limits` now +holds a number of exactly 7 characters (kept) and two alike but for case +(left out): undoing either bound of the 7 had failed no test. Each was +checked by undoing it and seeing its test fail. + +The GnuCash differential (`oracle/`) compares the engine's figures and +has no IIF and no package, so it has nothing to re-adjudicate here. + +Each of the review's fixes was checked by undoing it by hand and seeing +its test fail (2026-10-20): the 31-character cut, the clash loop's +second round, the double quote, the TIN mask, the memo cut, each reason +a number is left out, the memo grouping, the tag's colon; and, for the +builder's, the as_of `<=`, the register's presence, the 1099 `through`, +the closes filter, the FIXASSET date filter and the package's cash list. + +**For P12:** mutation over `ledger-import` `iif.rs` (each `breaks` arm, +the control, quote and `None` arms of `each_piece`, the deunicode +post-mapping, `segment`'s colon, trim, cut and "?" fallback, `tag`, +`with_tag`'s room, the clash loop's depth bound, rounds and tag, +`journal_to_iif`'s clash and length refusals, the number rules, the memo +cut, each `changes` comparison and its order, `warnings`' grouping, +order and masks, `shown`, DOCNUM, `EOL`), `pdf.rs` (the fallback font), +`ledger-package` `iif.rs` (`<=` in the as_of filter, the closes filter, +the FIXASSET date filter) and `package.rs` (the cash and financing +resolution, the register's presence, the 1099 `through`, the README's +IIF line, closes sentence, warnings block and 1099 wording), and +`ledger-core` `form1099.rs` (the `through` bound), `ledger-mcp` +`server.rs` (the iif report, range and as_of checks, `warnings`, the +package note), `ledger-gui` `menu.rs` (the note). From the second +review: `iif.rs` `iif_accounts`' type filter on parents and +`apart_from`, `parents_first`, the `AtTopLevel` change, the TIN note's +condition and `receivable_payable`; `fonts.rs` and `theme.rs` +`font_definitions`; the README's A/R and A/P sentence; `ledger-core` +`parties.rs` `tin_separator`'s arms and `tin_invisible`, and the gap that +doesn't count them. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work