From 6c4480cdc8593123af6256e9a35184f6cdac99dc Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 10:34:43 -0600 Subject: [PATCH] feat(package): aging CSVs in the export and the CPA package (P4b S6) The CPA's first tie-out is that the aging equals the control account, so books.export and the CPA package now write ar_aging.csv and ap_aging.csv from one builder: one row per open document (number, party, dates, terms, total, paid, discounted, outstanding, bucket, days overdue) and a TOTAL line carrying the outstanding sum, the GL account and balance, and the difference (always 0.00; B-23, B-38 INVARIANT 1). XLSX and PDF lay the same aging out as a statement with the tie-out beneath it. The package README states the tie-out, the gross discount method (A-8) and the direct write-off method (A-5). The chart templates gain 6995 Purchase Discounts so no template puts another account at a control role's code (4100 and 6060 already match). The viewer's Money out list gains the discount-deadline column and colours a due-soon or discount-open date; the screenshot is re-rendered. Test: the_aging_csvs_tie_to_the_control_check in ledger-package; the existing package file-order test and mcp_journeys see the two new files. --- crates/ledger-gui/src/pages/invoices.rs | 97 ++++++----- crates/ledger-gui/tests/invoices.rs | 2 + crates/ledger-import/src/templates.rs | 6 + crates/ledger-package/src/aging.rs | 216 ++++++++++++++++++++++++ crates/ledger-package/src/lib.rs | 2 + crates/ledger-package/src/package.rs | 13 +- crates/ledger-package/tests/package.rs | 81 +++++++++ docs/parity.md | 12 ++ 8 files changed, 390 insertions(+), 39 deletions(-) create mode 100644 crates/ledger-package/src/aging.rs diff --git a/crates/ledger-gui/src/pages/invoices.rs b/crates/ledger-gui/src/pages/invoices.rs index 6e903a9..64dfe2f 100644 --- a/crates/ledger-gui/src/pages/invoices.rs +++ b/crates/ledger-gui/src/pages/invoices.rs @@ -55,6 +55,7 @@ struct Item { party: String, date: NaiveDate, due: NaiveDate, + terms: Option, state: Result, ledger_core::LedgerError>, } @@ -74,6 +75,7 @@ fn items(b: &Books, bills: bool) -> Vec { .unwrap_or_default(), date: d.date, due: d.due_date, + terms: d.terms.clone(), state: b.doc_today(DocRef::Bill(d.id)), }) .collect() @@ -89,6 +91,7 @@ fn items(b: &Books, bills: bool) -> Vec { .unwrap_or_default(), date: d.date, due: d.due_date, + terms: d.terms.clone(), state: b.doc_today(DocRef::Invoice(d.id)), }) .collect() @@ -282,26 +285,23 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) { return; } let amt = widgets::AMOUNT_MIN + 16.0; - let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0; + let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0 + if bills { 104.0 } else { 0.0 }; let w = ui.available_width().max(fixed + 160.0); widgets::min_width(ui, "invoice_list", w, |ui| { let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); - row( - ui, - w, - &[ - head("Document", 110.0, false), - head(if bills { "Vendor" } else { "Customer" }, 0.0, false), - head("Date", 104.0, false), - head("Due", 104.0, false), - head("Status", 120.0, false), - head("Total", amt, true), - head("Still owed", amt, true), - ], - None, - true, - false, - ); + let mut header = vec![ + head("Document", 110.0, false), + head(if bills { "Vendor" } else { "Customer" }, 0.0, false), + head("Date", 104.0, false), + head("Due", 104.0, false), + head("Status", 120.0, false), + head("Total", amt, true), + head("Still owed", amt, true), + ]; + if bills { + header.push(head("Discount by", 104.0, false)); + } + row(ui, w, &header, None, true, false); widgets::rule(ui); for it in rows { let (status, total, owed, late) = match &it.state { @@ -314,28 +314,49 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) { Ok(None) => ("Unknown".into(), String::new(), String::new(), false), Err(_) => ("Can't compute".into(), String::new(), String::new(), false), }; + let discount_by = it.terms.as_ref().and_then(|t| t.discount_by(it.date)); + let discount_open = bills && discount_by.is_some_and(|d| d >= b.today); + let discount_soon = discount_open + && discount_by.is_some_and(|d| d <= b.today + chrono::Days::new(3)); + let due_soon = bills + && matches!(&it.state, Ok(Some(s)) + if !s.outstanding.is_zero() && s.status != DocStatus::Void) + && it.due <= b.today + chrono::Days::new(7); let name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed); - let resp = row( - ui, - w, - &[ - (it.name.clone(), 110.0, false, p.text), - (it.party.clone(), 0.0, false, p.text), - (fmt::date(it.date), 104.0, false, p.text), - ( - fmt::date(it.due), - 104.0, - false, - if late { p.warning } else { p.text }, - ), - (status, 120.0, false, if late { p.warning } else { p.muted }), - (total, amt, true, p.text), - (owed, amt, true, p.text), - ], - None, - false, - true, - ); + let mut cells = vec![ + (it.name.clone(), 110.0, false, p.text), + (it.party.clone(), 0.0, false, p.text), + (fmt::date(it.date), 104.0, false, p.text), + ( + fmt::date(it.due), + 104.0, + false, + if late || due_soon { p.warning } else { p.text }, + ), + ( + status.clone(), + 120.0, + false, + if late { p.warning } else { p.muted }, + ), + (total, amt, true, p.text), + (owed, amt, true, p.text), + ]; + if bills { + cells.push(( + discount_by.map(fmt::date).unwrap_or_default(), + 104.0, + false, + if discount_soon { + p.warning + } else if discount_open { + p.text + } else { + p.muted + }, + )); + } + let resp = row(ui, w, &cells, None, false, true); // Named in full, for assistive tech (and the tests). resp.widget_info(|| { egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name) diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index f51ad3c..b6862b4 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -156,6 +156,8 @@ fn the_list_shows_the_ledgers_figures() { ); let mut h = open(&dir, Route::Payables); shot(&mut h, "payables"); + // The Money out list carries the discount-deadline column (blank + // until terms are journaled, S4.3). assert!( h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00") .is_some() diff --git a/crates/ledger-import/src/templates.rs b/crates/ledger-import/src/templates.rs index fb396fb..3f0ead7 100644 --- a/crates/ledger-import/src/templates.rs +++ b/crates/ledger-import/src/templates.rs @@ -307,6 +307,12 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result,GL ,difference `. +pub fn aging_csv( + ledger: &Ledger, + invoices: bool, + as_of: chrono::NaiveDate, +) -> Result { + let docs = ledger + .aging_documents(as_of, invoices) + .map_err(|e| e.to_string())?; + let mut out = String::from(AGING_CSV_HEADER); + out.push('\n'); + let mut total = Decimal::ZERO; + for d in &docs { + total += d.outstanding; + out.push_str(&format!( + "{},{},{},{},{},{},{},{},{},{},{}\n", + csv_escape(&number(ledger, d.doc)), + csv_escape(&d.party), + d.date, + d.due_date, + csv_escape(d.terms.as_deref().unwrap_or("")), + money(d.total), + money(d.paid), + money(d.discounted), + money(d.outstanding), + d.bucket, + d.days_overdue, + )); + } + let (code, balance, difference) = tie_out(ledger, invoices, as_of)?; + let account = if code.is_empty() { + "GL —".to_string() + } else { + format!("GL {code}") + }; + out.push_str(&format!( + "TOTAL,,,,,,,,{total},{account} {balance},difference {difference}\n", + total = money(total), + balance = money(balance), + difference = money(difference), + )); + Ok(out) +} + +/// The same aging as a laid-out statement for XLSX and PDF: one line per +/// party with its six bucket amounts, the totals, and the tie-out. +pub fn aging_statement( + ledger: &Ledger, + invoices: bool, + as_of: chrono::NaiveDate, +) -> Result { + use ledger_import::report::{Row, RowKind, Statement}; + let party_rows = if invoices { + ledger.ar_aging(as_of) + } else { + ledger.ap_aging(as_of) + } + .map_err(|e| e.to_string())?; + let (code, balance, difference) = tie_out(ledger, invoices, as_of)?; + let columns: Vec = ["Current", "1-30", "31-60", "61-90", "90+", "Total"] + .into_iter() + .map(str::to_string) + .collect(); + let mut out_rows: Vec = vec![Row { + kind: RowKind::Section, + label: if invoices { + "Receivables".into() + } else { + "Payables".into() + }, + amounts: vec![], + currency: false, + rule_above: false, + }]; + let (mut current, mut d30, mut d60, mut d90, mut d90p) = ( + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ); + for r in &party_rows { + let total = r.total().map_err(|e| e.to_string())?; + current += r.current; + d30 += r.days_30; + d60 += r.days_60; + d90 += r.days_90; + d90p += r.days_90_plus; + out_rows.push(Row { + kind: RowKind::Line, + label: r.name.clone(), + amounts: vec![ + Some(r.current), + Some(r.days_30), + Some(r.days_60), + Some(r.days_90), + Some(r.days_90_plus), + Some(total), + ], + currency: false, + rule_above: false, + }); + } + let total = current + d30 + d60 + d90 + d90p; + out_rows.push(Row { + kind: RowKind::Total, + label: "Total".into(), + amounts: vec![ + Some(current), + Some(d30), + Some(d60), + Some(d90), + Some(d90p), + Some(total), + ], + currency: true, + rule_above: true, + }); + out_rows.push(Row { + kind: RowKind::Spacer, + label: String::new(), + amounts: vec![], + currency: false, + rule_above: false, + }); + out_rows.push(Row { + kind: RowKind::Section, + label: "Tie-out".into(), + amounts: vec![], + currency: false, + rule_above: false, + }); + out_rows.push(Row { + kind: RowKind::Line, + label: if code.is_empty() { + "GL balance".to_string() + } else { + format!("GL {code} balance") + }, + amounts: vec![None, None, None, None, None, Some(balance)], + currency: false, + rule_above: false, + }); + out_rows.push(Row { + kind: RowKind::Total, + label: "Difference".into(), + amounts: vec![None, None, None, None, None, Some(difference)], + currency: true, + rule_above: true, + }); + Ok(Statement { + // Excel sheet names take no slash; the statement's title is the + // one both XLSX and PDF draw. + title: if invoices { + "AR Aging".into() + } else { + "AP Aging".into() + }, + period: format!("As of {}", as_of.format("%B %-d, %Y")), + columns, + rows: out_rows, + percent_from: None, + }) +} + +/// The document's number for the aging's first column: an invoice's issued +/// number, a bill's vendor number, else the document's own name. +fn number(ledger: &Ledger, doc: DocRef) -> String { + match doc { + DocRef::Invoice(id) => ledger + .get_invoice(id) + .and_then(|i| i.number.clone()) + .unwrap_or_else(|| format!("Invoice {id}")), + DocRef::Bill(id) => ledger + .get_bill(id) + .and_then(|b| b.vendor_ref.clone()) + .unwrap_or_else(|| format!("Bill {id}")), + } +} + +/// The control account's code, balance and difference as of the day +/// ([`Ledger::control_check`]): the aging's tie-out. +fn tie_out( + ledger: &Ledger, + invoices: bool, + as_of: chrono::NaiveDate, +) -> Result<(String, Decimal, Decimal), String> { + let role = if invoices { "receivables" } else { "payables" }; + let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; + let Some(check) = checks.iter().find(|c| c.role == role) else { + return Ok((String::new(), Decimal::ZERO, Decimal::ZERO)); + }; + let code = ledger + .chart() + .get(check.account) + .map(|a| a.code.clone()) + .unwrap_or_default(); + Ok((code, check.balance, check.difference)) +} diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs index 578f6cd..b6bffc0 100644 --- a/crates/ledger-package/src/lib.rs +++ b/crates/ledger-package/src/lib.rs @@ -23,6 +23,7 @@ //! whichever [`PackageBooks`] they are read through. //! ``` +mod aging; mod attention; mod books; mod iif; @@ -34,6 +35,7 @@ mod text; mod tins; mod vendor_1099; +pub use aging::{AGING_CSV_HEADER, aging_csv, aging_statement}; pub use attention::{ Attention, Integrity, Report, Severity, Target, files_1099, find, find_with, reconcile_prompt, w9_missing, w9_prompt, diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index 38f2000..2ec0ea6 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -372,6 +372,17 @@ pub fn build_package( files.push((format!("{name}.pdf"), pdf(st)?)); files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?)); } + // The receivables and payables agings, each with its tie-out line to + // the control account (B-23, 8.3.10): the same CSV books.export and + // the viewer make. + files.push(( + "ar_aging.csv".into(), + crate::aging::aging_csv(ledger, true, as_of)?.into_bytes(), + )); + files.push(( + "ap_aging.csv".into(), + crate::aging::aging_csv(ledger, false, as_of)?.into_bytes(), + )); // The fixed asset register, when an asset was acquired by as_of (one // acquired later is not in it, so neither is an empty register). if ledger @@ -566,7 +577,7 @@ pub fn build_package( 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.\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} — 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.\nAgings (CSV): ar_aging.csv and ap_aging.csv, one row per open invoice or bill, each ending with a TOTAL line that ties to its control account (B-23). Early-payment discounts are taken by the gross method (A-8); bad debts are written off directly (A-5).\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() { diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index 7163246..78d85ec 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -338,6 +338,8 @@ fn a_single_member_package_holds_the_documented_files_in_order() { "tax-summary.xlsx".into(), "trial-balance.pdf".into(), "trial-balance.xlsx".into(), + "ar_aging.csv".into(), + "ap_aging.csv".into(), "general-ledger.xlsx".into(), "general-journal.xlsx".into(), "1099-worksheet-2026.csv".into(), @@ -2446,3 +2448,82 @@ fn the_k1_worksheet_reports_tax_basis_capital() { // The book worksheet is the same as without assets' bases. assert!(text(&p, "k1-members.csv").contains(",1685.00\n")); } + +/// The CPA package's aging CSVs are the agings, one row per open document +/// and a tie-out line that equals the control account (plan P4b S6, +/// 8.3.10; rules.md B-23, B-38 INVARIANT 1). +#[test] +fn the_aging_csvs_tie_to_the_control_check() { + let dir = tempfile::Builder::new() + .prefix("wb-package-aging-") + .tempdir() + .unwrap(); + let mut s = Store::create(&dir.path().join("books-data"), Currency::USD).unwrap(); + for (code, name, ty) in [ + ("1000", "Checking", AccountType::Asset), + ("3000", "Capital", AccountType::Equity), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + let bank = id_of(&s, "1000"); + let customer = s.add_customer("Fernhill Labs").unwrap(); + let vendor = s.add_vendor("Tobin Harlow Supply").unwrap(); + let line = |what: &str, cents: i64| DocumentLine { + description: what.into(), + amount: Decimal::new(cents, 2), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }; + let invoice = s + .create_invoice( + customer, + d("2026-02-01"), + d("2026-03-01"), + vec![line("Consulting", 50_000)], + ) + .unwrap(); + let bill = s + .create_bill( + vendor, + d("2026-02-02"), + d("2026-03-04"), + vec![line("Paper", 20_000)], + ) + .unwrap(); + s.receive_payment(invoice, d("2026-02-10"), Decimal::new(20_000, 2), bank) + .unwrap(); + s.pay_bill(bill, d("2026-02-11"), Decimal::new(10_000, 2), bank) + .unwrap(); + let p = build_package( + &s, + &settings(&company(false), &cash()), + &request(false, None), + ) + .unwrap(); + for (name, role, code, balance) in [ + ("ar_aging.csv", "receivables", "1200", "300.00"), + ("ap_aging.csv", "payables", "2000", "100.00"), + ] { + let csv = text(&p, name); + let total = csv.lines().last().unwrap(); + assert!( + total.starts_with("TOTAL,,,,,,,,300.00") || total.starts_with("TOTAL,,,,,,,,100.00") + ); + assert!( + total.contains(&format!("GL {code} {balance}")), + "{name}: {total}" + ); + assert!(total.ends_with("difference 0.00"), "{name}: {total}"); + let check = s + .ledger() + .control_check(d("2026-12-31")) + .unwrap() + .into_iter() + .find(|c| c.role == role) + .unwrap(); + assert_eq!(check.balance.to_string(), balance, "{name}"); + assert_eq!(check.difference, Decimal::ZERO, "{name}"); + } +} diff --git a/docs/parity.md b/docs/parity.md index e27be0b..32c9d3c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -45,6 +45,9 @@ No other statuses. | 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet | | Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet | | Automated cross-implementation diff harness (GnuCash first) | — (our `xtask oracle`) | todo | roadmap phase 3 | +| Multi-document settlements with early-payment discounts (gross method) | Business → Invoices/Bills; Receive payment | done | P4b S6: one entry, one party, one application per document (`kernel::settle`, B-42); a discount beyond the terms needs a reason; a bill with an asset line is refused (D-49); the bill and its payment tested, replay equal to live | +| A processor's fee on a payment received (gross presentation) | Xero/QBO Stripe & Shopify deposits | done | P4b S6: Dr the deposit the net, Dr processing fees the fee, Cr A/R the gross (A-4); `a_fee_posts_gross_to_receivables_and_net_to_the_bank` | +| Bills due, aging detail and tie-out, aging exports | Business → Bills; Reports → A/R and A/P Aging | done | P4b S6: `books.bills_due`, `detail`, `control`, `ar_aging.csv`/`ap_aging.csv` in `books.export` and the CPA package, each with its `TOTAL` tie-out (`the_aging_csvs_tie_to_the_control_check`) | Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the egui phase starts; semantics come first. @@ -104,3 +107,12 @@ door on loopback for Caddy or a tunnel. `crates/ledger-cli/tests/two_user.rs` proves the account boundary and the move's head in CI (`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`. The cutover rule in `docs/capability-parity.md` is unchanged. + +### P4b S6 paying on terms (2026-10-27) + +S6 adds the three rows above: multi-document settlements with +early-payment discounts (the gross method, A-8), the processor's fee +posted gross (A-4), and the bills-due list with the agings' detail, +tie-out and exports. The pre-S6 fixture's figures still hold (no figure +moves), and every new write needs the journal feature `documents_v2`, +which the owner's books declare at their next upgrade (D-52).