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
+390 -39

No files matched your search

+59 -38
View File
@@ -55,6 +55,7 @@ struct Item {
party: String,
date: NaiveDate,
due: NaiveDate,
terms: Option<ledger_core::Terms>,
state: Result<Option<DocState>, ledger_core::LedgerError>,
}
@@ -74,6 +75,7 @@ fn items(b: &Books, bills: bool) -> Vec<Item> {
.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<Item> {
.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)
+2
View File
@@ -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()
+6
View File
@@ -307,6 +307,12 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemp
"expense",
Some("nondeductible"),
),
acct(
"6995",
"Purchase Discounts",
"expense",
Some("other_deductions"),
),
acct(
"7100",
"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.
//! ```
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,
+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}.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<B: PackageBooks + ?Sized>(
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() {
+81
View File
@@ -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}");
}
}