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.
This commit is contained in:
swaits committed 2026-10-27 12:59:40 -06:00
1 parent 57f04dbb0c
commit 6c4480cdc8
8 files changed
+372 -21

No files matched your search

+41 -20
View File
@@ -55,6 +55,7 @@ struct Item {
party: String, party: String,
date: NaiveDate, date: NaiveDate,
due: NaiveDate, due: NaiveDate,
terms: Option<ledger_core::Terms>,
state: Result<Option<DocState>, ledger_core::LedgerError>, state: Result<Option<DocState>, ledger_core::LedgerError>,
} }
@@ -74,6 +75,7 @@ fn items(b: &Books, bills: bool) -> Vec<Item> {
.unwrap_or_default(), .unwrap_or_default(),
date: d.date, date: d.date,
due: d.due_date, due: d.due_date,
terms: d.terms.clone(),
state: b.doc_today(DocRef::Bill(d.id)), state: b.doc_today(DocRef::Bill(d.id)),
}) })
.collect() .collect()
@@ -89,6 +91,7 @@ fn items(b: &Books, bills: bool) -> Vec<Item> {
.unwrap_or_default(), .unwrap_or_default(),
date: d.date, date: d.date,
due: d.due_date, due: d.due_date,
terms: d.terms.clone(),
state: b.doc_today(DocRef::Invoice(d.id)), state: b.doc_today(DocRef::Invoice(d.id)),
}) })
.collect() .collect()
@@ -282,14 +285,11 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
return; return;
} }
let amt = widgets::AMOUNT_MIN + 16.0; 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); let w = ui.available_width().max(fixed + 160.0);
widgets::min_width(ui, "invoice_list", w, |ui| { widgets::min_width(ui, "invoice_list", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row( let mut header = vec![
ui,
w,
&[
head("Document", 110.0, false), head("Document", 110.0, false),
head(if bills { "Vendor" } else { "Customer" }, 0.0, false), head(if bills { "Vendor" } else { "Customer" }, 0.0, false),
head("Date", 104.0, false), head("Date", 104.0, false),
@@ -297,11 +297,11 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
head("Status", 120.0, false), head("Status", 120.0, false),
head("Total", amt, true), head("Total", amt, true),
head("Still owed", amt, true), head("Still owed", amt, true),
], ];
None, if bills {
true, header.push(head("Discount by", 104.0, false));
false, }
); row(ui, w, &header, None, true, false);
widgets::rule(ui); widgets::rule(ui);
for it in rows { for it in rows {
let (status, total, owed, late) = match &it.state { let (status, total, owed, late) = match &it.state {
@@ -314,11 +314,16 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
Ok(None) => ("Unknown".into(), String::new(), String::new(), false), Ok(None) => ("Unknown".into(), String::new(), String::new(), false),
Err(_) => ("Can't compute".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 name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed);
let resp = row( let mut cells = vec![
ui,
w,
&[
(it.name.clone(), 110.0, false, p.text), (it.name.clone(), 110.0, false, p.text),
(it.party.clone(), 0.0, false, p.text), (it.party.clone(), 0.0, false, p.text),
(fmt::date(it.date), 104.0, false, p.text), (fmt::date(it.date), 104.0, false, p.text),
@@ -326,16 +331,32 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
fmt::date(it.due), fmt::date(it.due),
104.0, 104.0,
false, false,
if late { p.warning } else { p.text }, if late || due_soon { p.warning } else { p.text },
),
(
status.clone(),
120.0,
false,
if late { p.warning } else { p.muted },
), ),
(status, 120.0, false, if late { p.warning } else { p.muted }),
(total, amt, true, p.text), (total, amt, true, p.text),
(owed, amt, true, p.text), (owed, amt, true, p.text),
], ];
None, if bills {
cells.push((
discount_by.map(fmt::date).unwrap_or_default(),
104.0,
false, false,
true, 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). // Named in full, for assistive tech (and the tests).
resp.widget_info(|| { resp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name) egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)
+2
View File
@@ -156,6 +156,8 @@ fn the_list_shows_the_ledgers_figures() {
); );
let mut h = open(&dir, Route::Payables); let mut h = open(&dir, Route::Payables);
shot(&mut h, "payables"); shot(&mut h, "payables");
// The Money out list carries the discount-deadline column (blank
// until terms are journaled, S4.3).
assert!( assert!(
h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00") h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00")
.is_some() .is_some()
+6
View File
@@ -307,6 +307,12 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemp
"expense", "expense",
Some("nondeductible"), Some("nondeductible"),
), ),
acct(
"6995",
"Purchase Discounts",
"expense",
Some("other_deductions"),
),
acct( acct(
"7100", "7100",
"Employee Benefits", "Employee Benefits",
+216
View File
@@ -0,0 +1,216 @@
//! The receivables and payables agings as files (plan P4b S6, 8.3.10;
//! rules.md B-18, B-23): one row per open document, and a tie-out line
//! against the control account. The server's `books.export` and the CPA
//! package build the same CSV from this, and its XLSX/PDF from
//! [`aging_statement`], so a CPA's two copies can't differ.
use ledger_core::{DocRef, Ledger};
use rust_decimal::Decimal;
use crate::text::{csv_escape, money};
/// The CSV's header: one row per open document (8.3.10).
pub const AGING_CSV_HEADER: &str =
"number,party,date,due_date,terms,total,paid,discounted,outstanding,bucket,days_overdue";
/// One aging's rows and tie-out as CSV: the header, one row per open
/// invoice (`invoices` true) or bill, then `TOTAL,,,,,,,,<Σ
/// outstanding>,GL <account> <balance>,difference <difference>`.
pub fn aging_csv(
ledger: &Ledger,
invoices: bool,
as_of: chrono::NaiveDate,
) -> Result<String, String> {
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<ledger_import::Statement, String> {
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<String> = ["Current", "1-30", "31-60", "61-90", "90+", "Total"]
.into_iter()
.map(str::to_string)
.collect();
let mut out_rows: Vec<Row> = 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))
}
+2
View File
@@ -23,6 +23,7 @@
//! whichever [`PackageBooks`] they are read through. //! whichever [`PackageBooks`] they are read through.
//! ``` //! ```
mod aging;
mod attention; mod attention;
mod books; mod books;
mod iif; mod iif;
@@ -34,6 +35,7 @@ mod text;
mod tins; mod tins;
mod vendor_1099; mod vendor_1099;
pub use aging::{AGING_CSV_HEADER, aging_csv, aging_statement};
pub use attention::{ pub use attention::{
Attention, Integrity, Report, Severity, Target, files_1099, find, find_with, reconcile_prompt, Attention, Integrity, Report, Severity, Target, files_1099, find, find_with, reconcile_prompt,
w9_missing, w9_prompt, w9_missing, w9_prompt,
+12 -1
View File
@@ -372,6 +372,17 @@ pub fn build_package<B: PackageBooks + ?Sized>(
files.push((format!("{name}.pdf"), pdf(st)?)); files.push((format!("{name}.pdf"), pdf(st)?));
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(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 // 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). // acquired later is not in it, so neither is an empty register).
if ledger if ledger
@@ -566,7 +577,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
let year_end = NaiveDate::from_ymd_opt(year, 12, 31) == Some(as_of); let year_end = NaiveDate::from_ymd_opt(year, 12, 31) == Some(as_of);
let include_tins = tins.is_some(); let include_tins = tins.is_some();
let readme = format!( 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, name = company.name,
basis = company.basis, basis = company.basis,
members = if company.members.is_empty() { members = if company.members.is_empty() {
+81
View File
@@ -338,6 +338,8 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
"tax-summary.xlsx".into(), "tax-summary.xlsx".into(),
"trial-balance.pdf".into(), "trial-balance.pdf".into(),
"trial-balance.xlsx".into(), "trial-balance.xlsx".into(),
"ar_aging.csv".into(),
"ap_aging.csv".into(),
"general-ledger.xlsx".into(), "general-ledger.xlsx".into(),
"general-journal.xlsx".into(), "general-journal.xlsx".into(),
"1099-worksheet-2026.csv".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. // The book worksheet is the same as without assets' bases.
assert!(text(&p, "k1-members.csv").contains(",1685.00\n")); 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}");
}
}
+12
View File
@@ -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 | | 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 | | 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 | | 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 Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
egui phase starts; semantics come first. 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 proves the account boundary and the move's head in CI
(`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`. (`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`.
The cutover rule in `docs/capability-parity.md` is unchanged. 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).