merge: P4b S6, paying on terms (branch S6, 6c4480cd)
The third of the paused branches: Ledger::settle with discounts and the processor's fee (kernel::settle, Kani settle_is_exact, Lean settle_posts_a_document_step), books.receive_payment and books.pay_bill with applications, books.bills_due, the agings' detail and tie-out, the aging exports and the CPA package's aging CSVs, the handlers in tools/settlements.rs. Twelve files conflicted with the S4.4 and S5 merges, resolved as plan 8.0.2 says: both routers; both test appends (the shared match arm in arap_standing.rs spliced into both tests; the shared `.unwrap()` in store.rs); the scope table at 124; one invoices-and-bills handbook (S5's send step 1 with S6's steps 2, 3, 5 and procedures) registered once; docs/mcp-interface.md with S4.4's create docs and S6's payment docs; S5's law renumbered 35 and S6's 36 (S1's is 34); the parity rows and sections of both. S6's wire tests create their bills as S4.4's contract now requires (confirmed, the vendor's number, a line account, the vendor's invoice uploaded) and live in their own module beside S4.4's tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
be58059830
45 files changed
+3949
-520
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user