diff --git a/bookspec/checker/src/rules.rs b/bookspec/checker/src/rules.rs index dc59220..34f0c90 100644 --- a/bookspec/checker/src/rules.rs +++ b/bookspec/checker/src/rules.rs @@ -555,8 +555,8 @@ const SPEC: Spec = &[ ( 42, Area::Arap, - "Settlements and the fee (allocated, not yet stated)", - None, + "A settlement is exact, one party, and names its lines; the gross discount and the gross fee", + Some(Status::Convention), &[], ), ( diff --git a/bookspec/spec/rules-index.md b/bookspec/spec/rules-index.md index 436f328..90c1b84 100644 --- a/bookspec/spec/rules-index.md +++ b/bookspec/spec/rules-index.md @@ -7,9 +7,9 @@ what its statement rests on and the examples that demonstrate it. A rule whose only source is a human choice carries rules.md's **CHOICE**, bookspec's status of the same name: the specification names the options, an engine declares its pick (`claims.choices`, `bookspec_checker::Claims`). Rules B-39 to B-45 are -allocated in `plan/served.md` 8.0.3 and not yet stated: their -identifier and area are fixed here so a later unit fills them in -without moving anyone else's number. +allocated in `plan/served.md` 8.0.3; those not yet stated keep only +their identifier and area here, so a later unit fills them in without +moving anyone else's number. | B | bookspec id | area | status | rule | examples | |---|---|---|---|---|---| @@ -54,7 +54,7 @@ without moving anyone else's number. | B-39 | `BS-arap-3` | arap | — | Terms: when a document is due (allocated, not yet stated) | — | | B-40 | `BS-arap-4` | arap | — | Numbers and the issued copy (allocated, not yet stated) | — | | B-41 | `BS-arap-5` | arap | — | Write-offs (allocated, not yet stated) | — | -| B-42 | `BS-arap-6` | arap | — | Settlements and the fee (allocated, not yet stated) | — | +| B-42 | `BS-arap-6` | arap | CONVENTION | A settlement is exact, one party, and names its lines; the gross discount and the gross fee | — | | B-43 | `BS-entry-5` | entry | — | Drafts and evidence (allocated, not yet stated) | — | | B-44 | `BS-ledger-12` | ledger | — | Extraction and dedup (allocated, not yet stated) | — | | B-45 | `BS-ledger-13` | ledger | — | Audits and attestations (allocated, not yet stated) | — | diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs index 9f5e817..6e4d6db 100644 --- a/crates/ledger-cli/tests/documents.rs +++ b/crates/ledger-cli/tests/documents.rs @@ -795,3 +795,420 @@ fn mark_sent_records_who_and_how() { "a send changes no figure" ); } + +// P4b S6: paying on terms (merged from branch S6), in its own module so its +// helpers keep their names beside S4.4's. +mod paying_on_terms { + use super::common::answer_of; + use super::*; + + /// Call a tool and unwrap its answer. + fn ok(w: &mut Wire, tool: &str, args: Value) -> Value { + answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{tool}: {e}")) + } + + /// The refusal's text. + fn err(w: &mut Wire, tool: &str, args: Value) -> String { + answer_of(&w.call(tool, args)).expect_err(&format!("{tool} was accepted")) + } + + struct Setup { + _books: Books, + wire: Wire, + customer: u64, + vendor: u64, + bills: u32, + } + + /// Bank 1000, card 2050, capital 3000, a customer and a vendor, then a + /// session. + fn setup(tag: &str) -> Setup { + let books = Books::new(tag); + let mut wire = books.session("documents"); + ok( + &mut wire, + "books.add_account", + json!({"code": "1000", "name": "Bank", "account_type": "asset"}), + ); + ok( + &mut wire, + "books.add_account", + json!({"code": "2050", "name": "Card", "account_type": "liability"}), + ); + ok( + &mut wire, + "books.add_account", + json!({"code": "3000", "name": "Capital", "account_type": "equity"}), + ); + ok( + &mut wire, + "books.add_account", + json!({"code": "6999", "name": "General Expenses", "account_type": "expense"}), + ); + let customer = ok( + &mut wire, + "books.add_customer", + json!({"name": "Fernhill Labs"}), + )["id"] + .as_u64() + .unwrap(); + let vendor = ok( + &mut wire, + "books.add_vendor", + json!({"name": "Tobin Harlow Supply"}), + )["id"] + .as_u64() + .unwrap(); + Setup { + _books: books, + wire, + customer, + vendor, + bills: 0, + } + } + + impl Setup { + fn invoice(&mut self, date: &str, due: &str, amount: &str) -> u64 { + ok( + &mut self.wire, + "books.create_invoice", + json!({"party_id": self.customer, "date": date, "due_date": due, + "lines": [{"description": "Consulting", "amount": amount}], + "confirmed": true}), + )["id"] + .as_u64() + .unwrap() + } + + fn bill(&mut self, date: &str, due: &str, amount: &str) -> u64 { + // Under documents_v2 (P4b S4.4) a bill names the vendor's invoice + // number, each line's account, and carries the vendor's invoice. + self.bills += 1; + let file = self.wire.upload( + format!("vendor invoice TH-{}", self.bills).as_bytes(), + "bill.pdf", + "application/pdf", + "receipt", + ); + ok( + &mut self.wire, + "books.create_bill", + json!({"party_id": self.vendor, "date": date, "due_date": due, + "vendor_ref": format!("TH-{}", self.bills), + "lines": [{"description": "Paper", "amount": amount, "account": "6999"}], + "upload_id": file, "confirmed": true}), + )["id"] + .as_u64() + .unwrap() + } + + fn line(&mut self, entry: u64, code: &str) -> (String, String, String) { + let e = ok( + &mut self.wire, + "books.get_entry", + json!({"entry_id": entry}), + ); + for l in e["lines"].as_array().unwrap() { + if l["code"] == json!(code) { + return ( + l["side"].as_str().unwrap().to_string(), + l["amount"].as_str().unwrap().to_string(), + code.to_string(), + ); + } + } + panic!("{code} not in entry {entry}: {e}"); + } + } + + /// One deposit settles two invoices of one customer: one entry, one + /// receivable line per invoice, each tagged with its own invoice, and the + /// output names both documents (B-42 INVARIANTS 1 and 3). + #[test] + fn one_payment_settles_two_invoices() { + let mut s = setup("settle-two"); + let first = s.invoice("2026-02-01", "2026-03-01", "500.00"); + let second = s.invoice("2026-02-05", "2026-04-30", "80.00"); + let out = ok( + &mut s.wire, + "books.receive_payment", + json!({"date": "2026-02-10", "deposit_to": "1000", + "reference": "one deposit", + "applications": [ + {"invoice": first, "amount": "300.00"}, + {"invoice": second, "amount": "80.00"} + ]}), + ); + assert_eq!(out["deposited"], json!("380.00")); + let refs: Vec<&str> = out["documents"] + .as_array() + .unwrap() + .iter() + .map(|d| d["ref"].as_str().unwrap()) + .collect(); + assert_eq!( + refs, + [format!("invoice:{first}"), format!("invoice:{second}")] + ); + assert_eq!(out["documents"][0]["outstanding"], json!("200.00")); + assert_eq!(out["documents"][1]["status"], json!("paid")); + // The entry: one deposit line, two receivable lines. + let entry = out["entry_id"].as_u64().unwrap(); + let e = ok(&mut s.wire, "books.get_entry", json!({"entry_id": entry})); + let lines = e["lines"].as_array().unwrap(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0]["side"], json!("credit")); + assert_eq!(lines[0]["amount"], json!("300.00")); + assert_eq!(lines[1]["side"], json!("credit")); + assert_eq!(lines[1]["amount"], json!("80.00")); + // Each invoice's timeline shows its own payment from this entry. + for (id, paid) in [(first, "300.00"), (second, "80.00")] { + let d = ok( + &mut s.wire, + "books.document", + json!({"ref": format!("invoice:{id}"), "as_of": "2026-12-31"}), + ); + let payment = d["timeline"] + .as_array() + .unwrap() + .iter() + .find(|t| t["kind"] == json!("payment")) + .unwrap(); + assert_eq!(payment["entry_id"].as_u64().unwrap(), entry); + assert_eq!(payment["amount"], json!(format!("-{paid}"))); + } + } + + /// A discount outside the document's terms needs a reason; with one the + /// payment is taken and recorded (B-42 INVARIANT 2; the terms are S4.3's + /// to journal, so this bill states none). + #[test] + fn late_discount_needs_a_reason() { + let mut s = setup("late-discount"); + let bill = s.bill("2026-02-01", "2026-03-03", "100.00"); + let applications = |reason: Option<&str>| { + let mut a = json!([{"bill": bill, "amount": "98.00", "discount": "2.00"}]); + if let Some(reason) = reason { + a[0]["discount_reason"] = json!(reason); + } + a + }; + let why = err( + &mut s.wire, + "books.pay_bill", + json!({"date": "2026-02-05", "paid_from": "1000", "applications": applications(None)}), + ); + assert!(why.contains("needs a reason"), "{why}"); + assert!(why.contains("discount_reason"), "{why}"); + ok( + &mut s.wire, + "books.pay_bill", + json!({"date": "2026-02-05", "paid_from": "1000", + "applications": applications(Some("the vendor honoured it late"))}), + ); + } + + /// A fee posts gross (A-4): the deposit gets the net, the fee its expense, + /// and A/R is credited the gross (B-42 INVARIANT 1). + #[test] + fn a_fee_posts_gross_to_receivables_and_net_to_the_bank() { + let mut s = setup("fee-gross"); + let invoice = s.invoice("2026-02-01", "2026-03-01", "100.00"); + let out = ok( + &mut s.wire, + "books.receive_payment", + json!({"date": "2026-02-05", "deposit_to": "1000", + "invoice": invoice, "amount": "100.00", + "fee": {"amount": "3.00"}}), + ); + assert_eq!(out["deposited"], json!("97.00")); + assert_eq!(out["status"], json!("paid")); + let entry = out["entry_id"].as_u64().unwrap(); + assert_eq!( + s.line(entry, "1200"), + ("credit".into(), "100.00".into(), "1200".into()) + ); + assert_eq!( + s.line(entry, "6060"), + ("debit".into(), "3.00".into(), "6060".into()) + ); + assert_eq!( + s.line(entry, "1000"), + ("debit".into(), "97.00".into(), "1000".into()) + ); + } + + /// A bill paid by card is excluded from 1099-NEC (D-40): the worksheet + /// counts it as card, not as paid. + #[test] + fn card_paid_bill_is_not_1099_nec() { + let mut s = setup("card-1099"); + ok( + &mut s.wire, + "books.add_instrument", + json!({"kind": "business_card", "label": "Visa", "last4": "4242", "account": "2050"}), + ); + let bill = s.bill("2026-02-01", "2026-03-03", "100.00"); + ok( + &mut s.wire, + "books.pay_bill", + json!({"date": "2026-02-05", "paid_from": "2050", "bill": bill, "amount": "100.00", + "instrument": "Visa"}), + ); + let worksheet = ok( + &mut s.wire, + "books.vendor_1099", + json!({"year": 2026, "cash_accounts": ["1000"]}), + ); + let line = worksheet["vendors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["vendor_id"] == json!(s.vendor)) + .unwrap(); + assert_eq!(line["paid"], json!("0.00"), "{worksheet}"); + assert_eq!(line["excluded_card"], json!("100.00"), "{worksheet}"); + } + + /// Two bills paid in one entry count once toward 1099s and the party's + /// totals (B-42; 8.3.16's multi-document risk). + #[test] + fn multi_document_payment_counts_once_for_1099_and_party_totals() { + let mut s = setup("multi-1099"); + let first = s.bill("2026-02-01", "2026-03-03", "100.00"); + let second = s.bill("2026-02-02", "2026-03-04", "200.00"); + ok( + &mut s.wire, + "books.pay_bill", + json!({"date": "2026-02-05", "paid_from": "1000", + "applications": [ + {"bill": first, "amount": "100.00"}, + {"bill": second, "amount": "200.00"} + ]}), + ); + let worksheet = ok( + &mut s.wire, + "books.vendor_1099", + json!({"year": 2026, "cash_accounts": ["1000"]}), + ); + let line = worksheet["vendors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["vendor_id"] == json!(s.vendor)) + .unwrap(); + assert_eq!(line["paid"], json!("300.00"), "{worksheet}"); + let parties = ok(&mut s.wire, "books.parties", json!({"year": 2026})); + let party = parties["parties"] + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == json!("Tobin Harlow Supply")) + .unwrap(); + assert_eq!(party["paid"], json!("300.00"), "{parties}"); + } + + /// An overpayment is refused showing what the document owes and the + /// remedy for the excess (A-12, D-49). + #[test] + fn overpayment_refused_with_the_excess() { + let mut s = setup("overpayment"); + let invoice = s.invoice("2026-02-01", "2026-03-01", "100.00"); + let why = err( + &mut s.wire, + "books.receive_payment", + json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "150.00"}), + ); + assert!(why.contains("150.00"), "{why}"); + assert!(why.contains("100.00"), "{why}"); + assert!(why.contains("excess"), "{why}"); + assert!(why.contains("2300"), "{why}"); + } + + /// The bills-due list orders by due date and gives the running total + /// through each date; the agings' detail rows and their tie-out agree with + /// the control check (8.3.10; B-42). + #[test] + fn bills_due_orders_by_due_and_discount() { + let mut s = setup("bills-due"); + s.bill("2026-02-01", "2026-02-20", "100.00"); + s.bill("2026-02-02", "2026-02-10", "250.00"); + s.bill("2026-02-03", "2026-03-10", "400.00"); + let due = ok( + &mut s.wire, + "books.bills_due", + json!({"through": "2026-02-28", "as_of": "2026-02-05"}), + ); + let dates: Vec<&str> = due["bills"] + .as_array() + .unwrap() + .iter() + .map(|b| b["due_date"].as_str().unwrap()) + .collect(); + assert_eq!(dates, ["2026-02-10", "2026-02-20"]); + assert_eq!( + due["by_date"], + json!([ + {"date": "2026-02-10", "amount": "250.00"}, + {"date": "2026-02-20", "amount": "350.00"} + ]) + ); + // The discount fields exist only when the bill's terms give one, which + // S4.3 journals: without them they are absent, not zero. + assert!(due["bills"][0].get("discount_by").is_none()); + let aging = ok( + &mut s.wire, + "books.ap_aging", + json!({"as_of": "2026-02-05", "detail": true}), + ); + assert_eq!(aging["control"]["difference"], json!("0.00"), "{aging}"); + assert_eq!(aging["control"]["documents_total"], json!("750.00")); + let rows = aging["documents"].as_array().unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0]["ref"], json!(format!("bill:{}", 2))); + assert_eq!(rows[0]["bucket"], json!("current")); + // A/R is empty and still ties. + let ar = ok( + &mut s.wire, + "books.ar_aging", + json!({"as_of": "2026-02-05", "detail": true}), + ); + assert_eq!(ar["control"]["balance"], json!("0.00")); + assert_eq!(ar["control"]["difference"], json!("0.00")); + assert!(ar.get("documents").is_none()); + } + + /// The aging exports are the same CSV the CPA package carries: one row per + /// document and a tie-out line, and the CPA package's CSVs tie to the + /// control account (8.3.10; ledger-package's test repeats the tie-out). + #[test] + fn the_aging_export_ties_out() { + let mut s = setup("aging-export"); + let invoice = s.invoice("2026-02-01", "2026-03-01", "500.00"); + ok( + &mut s.wire, + "books.receive_payment", + json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "200.00"}), + ); + let out = ok( + &mut s.wire, + "books.export", + json!({"format": "csv", "report": "ar_aging", "as_of": "2026-02-28"}), + ); + let csv = s.wire.fetch_text(&out["file"]); + let mut lines = csv.lines(); + assert_eq!( + lines.next(), + Some( + "number,party,date,due_date,terms,total,paid,discounted,outstanding,bucket,days_overdue" + ) + ); + assert!(lines.next().unwrap().contains("500.00")); + let total = lines.next().unwrap(); + assert!(total.starts_with("TOTAL,,,,,,,,"), "{total}"); + assert!(total.contains("300.00"), "{total}"); + assert!(total.contains("GL 1200 300.00"), "{total}"); + assert!(total.ends_with("difference 0.00"), "{total}"); + } +} diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index 5e7cf89..decf75f 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -1222,6 +1222,8 @@ fn mcp_statement_pdfs_and_cpa_package() { [ "1099-worksheet-2026.csv", "README.txt", + "ap_aging.csv", + "ar_aging.csv", "balance-sheet.pdf", "balance-sheet.xlsx", "cash-flow.pdf", diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index 39670f2..a2324a3 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -887,6 +887,7 @@ fn happy_path(name: &str) -> Mcp { ); m.ok("books.ar_aging", json!({"as_of": "2026-06-30"})); m.ok("books.ap_aging", json!({"as_of": "2026-06-30"})); + m.ok("books.bills_due", json!({"through": "2026-06-30"})); // P4b S1: a document voided, and the control accounts' tie-out. let dup = m.ok( "books.create_invoice", @@ -1193,10 +1194,14 @@ fn happy_path(name: &str) -> Mcp { ("csv", "general_ledger"), ("json", "members_equity"), ("csv", "tax_summary"), + ("csv", "ar_aging"), + ("xlsx", "ar_aging"), + ("pdf", "ap_aging"), ] { m.ok( "books.export", - json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}), + json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31", + "as_of": "2026-12-31"}), ); } // The IIF takes as_of (every posted entry from the first through it), diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index f3290e0..387ece4 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -425,6 +425,64 @@ impl PaymentDetails { } } +/// One document a settlement applies to (plan P4b S6; rules.md B-42): the +/// cash paid on it, the early-payment discount taken off it, and why a +/// discount outside its terms was taken anyway. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Application { + /// The invoice or bill it applies to. + pub doc: DocRef, + /// The cash paid against it, positive. + pub cash: Decimal, + /// The early-payment discount taken, zero when none. + pub discount: Decimal, + /// Why a discount outside the document's terms was taken (a vendor who + /// honours a late discount): one line, journaled with the payment. + pub discount_reason: Option, +} + +/// A processor's fee withheld from a customer payment (plan P4b S6; A-4): +/// receivables are credited the gross, the fee goes to its expense +/// account, and the deposit account gets the net. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Fee { + /// The fee, positive. + pub amount: Decimal, + /// The expense account it goes to; `None` is the + /// `payment_processing_fees` role's account. + pub account: Option, + /// The processor, when the books know it as a vendor. + pub vendor: Option, +} + +/// Everything one settlement needs (plan P4b S6; rules.md B-42): its date, +/// description, deposit account, the party every document belongs to, the +/// applications, and the processor's fee when one was withheld. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SettleParams { + /// The settlement's date. + pub date: NaiveDate, + /// The entry's description. + pub description: String, + /// The account the cash reaches (or leaves): a bank, a card, a payable + /// holding no role. + pub cash_account: crate::AccountId, + /// The customer or vendor every application names. + pub party: u64, + /// One or more documents, one party, one kind. + pub applications: Vec, + /// The processor's fee, on a customer payment only. + pub fee: Option, + /// The payment method, when known (D-40). + pub instrument: Option, + /// The processor's or bank's reference, when there is one. + pub reference: Option, +} + +/// One posted line a settlement's plan asks for, with the document it tags +/// when it is a control line ([`Application`]'s payment or discount). +pub type SettlementRow = (crate::AccountId, crate::Side, Decimal, Option); + /// How a document's payment terms count their days (rules.md B-39; plan /// P4b S4.1): from the document's date, from the end of its month, or a /// date the parties agreed. @@ -1264,6 +1322,35 @@ impl AgingRow { } } +/// One document in an aging's detail: its figures and the bucket it falls +/// in (plan P4b S6, 8.3.10). Every figure is derived from its tagged lines +/// ([`crate::Ledger::doc_state`]). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AgingDocument { + /// The invoice or bill. + pub doc: DocRef, + /// The customer or vendor. + pub party: String, + /// Its own date. + pub date: NaiveDate, + /// When payment is due. + pub due_date: NaiveDate, + /// Its terms, as they print ("2/10 net 30"), when it has them. + pub terms: Option, + /// The sum of its lines. + pub total: Decimal, + /// Cash paid that stands. + pub paid: Decimal, + /// Discounts taken that stand. + pub discounted: Decimal, + /// What it still owes as of the day. + pub outstanding: Decimal, + /// `current`, `days_30`, `days_60`, `days_90` or `days_90_plus`. + pub bucket: &'static str, + /// Days past its due date while something is owed; 0 otherwise. + pub days_overdue: i64, +} + /// Put an open balance in its aging bucket. /// /// ```text @@ -1296,23 +1383,35 @@ pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> days_90: Decimal::ZERO, days_90_plus: Decimal::ZERO, }; - if due_date >= as_of { - row.current = balance; - } else { - let days = (as_of - due_date).num_days(); - if days <= 30 { - row.days_30 = balance; - } else if days <= 60 { - row.days_60 = balance; - } else if days <= 90 { - row.days_90 = balance; - } else { - row.days_90_plus = balance; - } + match bucket_key(due_date, as_of) { + "current" => row.current = balance, + "days_30" => row.days_30 = balance, + "days_60" => row.days_60 = balance, + "days_90" => row.days_90 = balance, + _ => row.days_90_plus = balance, } row } +/// The aging bucket a document due `due_date` falls in as of `as_of`, by +/// the edges [`aging_bucket`] uses: `current` (due today is current), +/// `days_30`, `days_60`, `days_90` or `days_90_plus`. +pub fn bucket_key(due_date: NaiveDate, as_of: NaiveDate) -> &'static str { + if due_date >= as_of { + return "current"; + } + let days = (as_of - due_date).num_days(); + if days <= 30 { + "days_30" + } else if days <= 60 { + "days_60" + } else if days <= 90 { + "days_90" + } else { + "days_90_plus" + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 55272b5..f9a2211 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -676,9 +676,80 @@ pub enum LedgerError { InvalidPaymentAmount(Decimal), /// Payment exceeds the remaining balance. - #[error("payment of {0} exceeds remaining balance of {1}")] + #[error( + "payment of {0} exceeds remaining balance of {1}; the difference is the excess: record it as a plain entry to a customer-deposits liability (2300) and apply it later" + )] Overpayment(Decimal, Decimal), + /// A settlement named no documents. + #[error("a settlement needs at least one invoice or bill")] + SettlementEmpty, + + /// A settlement's documents aren't all invoices or all bills. + #[error("a settlement cannot mix invoices and bills: {0} is not the same kind as the others")] + SettlementMixedKinds(String), + + /// A settlement's documents don't all belong to the one party it + /// names (rules.md B-42 INVARIANT 1). + #[error("{0} is not {1}'s: a settlement covers one party only")] + SettlementPartiesDiffer(String, u64), + + /// A discount needs a reason: none is stated in the document's terms, + /// or it is larger than its terms allow, or it is taken after the + /// discount date (rules.md B-42 INVARIANT 2). + #[error( + "{doc}: the discount of {discount} needs a reason ({because}); pass discount_reason with the payment" + )] + DiscountNeedsReason { + /// The document. + doc: String, + /// The discount taken. + discount: Decimal, + /// Why the terms don't allow it as a matter of course. + because: String, + }, + + /// A discount on a bill whose lines include an asset or prepaid + /// account is refused: apportioning it across those lines is deferred + /// (D-49). + #[error( + "{doc} has a line on {code} \"{name}\" (an asset or prepaid account), so a discount on it cannot be apportioned (D-49): record the bill net of the discount, or reclassify by an entry Dr 6995 Purchase Discounts / Cr {code}" + )] + DiscountOnAssetBill { + /// The bill. + doc: String, + /// The asset line's account code. + code: String, + /// Its name. + name: String, + }, + + /// A settlement names the same document twice: each document takes one + /// application (its cash and its discount), so its bound is one bound. + #[error("{0} appears twice in one settlement: name it once, with all its cash and discount")] + SettlementDuplicateDocument(String), + + /// A settlement's discount reason is not one line of at most + /// [`crate::arap::MAX_REFERENCE_CHARS`] characters. + #[error("a discount's reason is one line of at most {0} characters")] + InvalidDiscountReason(usize), + + /// A processor's fee is a customer payment's only: a bill is paid from + /// the company's own money, with no processor taking a cut. + #[error("a processor's fee belongs on a customer payment, not on paying a bill")] + FeeOnBill, + + /// A fee's amount or account the books can't take: the message says + /// why. + #[error("{0}")] + InvalidFee(String), + + /// A settlement's figures can't be exact: the cash and discounts + /// don't cover the applications, or the fee leaves a negative deposit + /// (rules.md B-42 INVARIANT 1). + #[error("{0}")] + InvalidSettlement(String), + /// A payment's details were refused (its reference): the message says /// why. #[error("{0}")] diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 9881b12..5a99d08 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -269,6 +269,63 @@ pub fn discount_max(total: T, percent_bp: u32) -> Option { total.checked_mul(bp)?.checked_div_exact(ten_thousand) } +/// What a settlement of one or more documents comes to: the applications' +/// total (cash plus discounts taken) and what reaches the deposit account +/// (the cash less the processor's fee). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Settlement { + /// Σ cash + Σ discounts: what the settlement applies to the documents. + pub applications_total: T, + /// Σ cash − fee: what the deposit account receives (never negative). + pub deposit: T, +} + +/// The arithmetic of a settlement (rules.md B-42; plan P4b S6): each +/// document's cash and discount, and the processor's fee withheld from the +/// cash that reaches the bank. +/// +/// ```text +/// GOAL: a settlement is exact: what the documents are settled by is the +/// cash plus the discounts, and the deposit is the cash less the fee. +/// SOURCE: docs/spec/rules.md B-42; plan/served.md 8.3.6, 8.3.13 (B-42); +/// CONVENTION; DECISION: the gross method (A-8) and the fee's +/// gross presentation (A-4). +/// INVARIANTS: +/// 1. Some(s): s.applications_total = Σ cash + Σ discounts and +/// s.deposit = Σ cash − fee, exactly, with s.deposit >= 0. +/// 2. None on overflow, a negative amount, a negative fee, a fee above +/// the cash, or arrays of different lengths: never a wrapped or +/// rounded figure. +/// ``` +pub fn settle(cash: &[T], discounts: &[T], fee: T) -> Option> { + if cash.len() != discounts.len() || fee < T::ZERO { + return None; + } + let mut total_cash = T::ZERO; + for &amount in cash { + if amount < T::ZERO { + return None; + } + total_cash = total_cash.checked_add(amount)?; + } + let mut total_discounts = T::ZERO; + for &amount in discounts { + if amount < T::ZERO { + return None; + } + total_discounts = total_discounts.checked_add(amount)?; + } + let applications_total = total_cash.checked_add(total_discounts)?; + let deposit = total_cash.checked_sub(fee)?; + if deposit < T::ZERO { + return None; + } + Some(Settlement { + applications_total, + deposit, + }) +} + /// A document line's amount from its quantity and unit price: the exact /// product, never rounded. /// diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 35ffd08..4b4dcb2 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -91,6 +91,34 @@ pub const GENERAL_EXPENSES: ControlAccount = control( "bill expense", AccountType::Expense, ); +/// A control account: discounts given to customers (plan P4b S6, A-8; plan +/// 8.3.4): a debit-balance revenue account, the gross method's allowance +/// when a customer takes an early-payment discount. +pub const SALES_ALLOWANCES: ControlAccount = control( + "sales_allowances", + "4100", + "Refunds and Allowances", + "sales allowances", + AccountType::Revenue, +); +/// A control account: discounts taken from vendors (plan P4b S6, A-8): a +/// credit-balance expense account, the gross method's purchase discount. +pub const PURCHASE_DISCOUNTS: ControlAccount = control( + "purchase_discounts", + "6995", + "Purchase Discounts", + "purchase discounts", + AccountType::Expense, +); +/// A control account: the processor's fee on a payment received (plan P4b +/// S6, A-4): the gross presentation's fee expense. +pub const PAYMENT_PROCESSING_FEES: ControlAccount = control( + "payment_processing_fees", + "6060", + "Platform and Payment Processing Fees", + "payment processing fees", + AccountType::Expense, +); /// A control account; see [`ControlAccount`]. pub const RETAINED_EARNINGS: ControlAccount = control( "retained_earnings", @@ -6223,6 +6251,415 @@ impl Ledger { } } + /// Settle one or more invoices or bills in one entry (plan P4b S6; + /// rules.md B-42): cash against each, an early-payment discount where + /// the document's terms allow it (or a reason says why), and the + /// processor's fee on a customer payment, posted gross. + /// + /// ```text + /// GOAL: record a payment that settles several documents at once, with + /// the early-payment discounts and the processor's fee, exactly. + /// SOURCE: docs/spec/rules.md B-42; plan/served.md 8.3.6, 8.3.13; + /// CONVENTION; DECISION: the gross method (A-8) and the fee's + /// gross presentation (A-4). + /// INVARIANTS: + /// 1. Σ cash + Σ discounts = Σ applications and deposit = Σ cash − + /// fee (kernel::settle); one party per settlement. + /// 2. Each application's cash + discount ≤ what the document owes on + /// every day from the payment's date on. + /// 3. Posting rows: payment lines in application order, then the + /// discount lines, then the fee line, then the cash line, so a + /// journaled event's line indices are stable. + /// 4. A discount is at most kernel::discount_max(total, percent_bp) + /// and dated on or before terms.discount_by(date), else a reason + /// is required; a discount on a bill with an asset line is + /// refused (D-49); a fee is refused on a bill. + /// 5. A refusal MUST leave the books unchanged. + /// ``` + pub fn settle(&mut self, params: arap::SettleParams) -> Result { + self.atomically(|l| l.settle_steps(params)) + } + + /// The lines [`Self::settle`] would post, after every check, in its + /// order (payment lines, discount lines, the fee line, the cash line). + /// A store journals exactly these before it mutates; it must have + /// created the role accounts [`Self::settle`] would create first. + pub fn settlement_lines( + &self, + params: &arap::SettleParams, + ) -> Result, LedgerError> { + use arap::{DocRef, DocTag, Movement}; + + if params.applications.is_empty() { + return Err(LedgerError::SettlementEmpty); + } + let kind = match params.applications[0].doc { + DocRef::Invoice(_) => arap::DocumentKind::Invoice, + DocRef::Bill(_) => arap::DocumentKind::Bill, + }; + let control = match kind { + arap::DocumentKind::Invoice => RECEIVABLES, + arap::DocumentKind::Bill => PAYABLES, + }; + let discount_role = match kind { + arap::DocumentKind::Invoice => SALES_ALLOWANCES, + arap::DocumentKind::Bill => PURCHASE_DISCOUNTS, + }; + for app in ¶ms.applications { + let same = matches!( + (kind, app.doc), + (arap::DocumentKind::Invoice, DocRef::Invoice(_)) + | (arap::DocumentKind::Bill, DocRef::Bill(_)) + ); + if !same { + return Err(LedgerError::SettlementMixedKinds(app.doc.to_string())); + } + let party = match app.doc { + DocRef::Invoice(id) => { + self.invoices + .get(&id) + .ok_or(LedgerError::UnknownInvoice(id))? + .customer_id + } + DocRef::Bill(id) => { + self.bills + .get(&id) + .ok_or(LedgerError::UnknownBill(id))? + .vendor_id + } + }; + if party != params.party { + return Err(LedgerError::SettlementPartiesDiffer( + app.doc.to_string(), + params.party, + )); + } + } + crate::calendar::check(params.date)?; + if let Some(pid) = self.closed_period_containing(params.date) { + return Err(LedgerError::ClosedPeriod(pid, params.date.to_string())); + } + let details = arap::PaymentDetails { + instrument: params.instrument, + reference: params.reference.clone(), + }; + details.check()?; + if let Some(i) = params.instrument + && !self.instruments.contains_key(&i) + { + return Err(LedgerError::UnknownInstrument(i)); + } + self.check_controls(&[control])?; + self.check_payment_account(params.cash_account)?; + let control_account = self.role_account(&control).ok_or_else(|| { + LedgerError::InvalidSettlement(format!( + "{} has no account in these books yet", + control.role + )) + })?; + + let mut cash = Vec::with_capacity(params.applications.len()); + let mut discounts = Vec::with_capacity(params.applications.len()); + for (i, app) in params.applications.iter().enumerate() { + if params.applications[..i] + .iter() + .any(|earlier| earlier.doc == app.doc) + { + return Err(LedgerError::SettlementDuplicateDocument( + app.doc.to_string(), + )); + } + if app.cash <= Decimal::ZERO { + return Err(LedgerError::InvalidPaymentAmount(app.cash)); + } + if app.discount < Decimal::ZERO { + return Err(LedgerError::InvalidSettlement(format!( + "{}: a discount cannot be negative", + app.doc + ))); + } + let (doc_date, _, _, total) = self.doc_header(app.doc)?; + if params.date < doc_date { + return Err(LedgerError::PaymentBeforeDocument { + date: params.date, + document_date: doc_date, + }); + } + let room = self.payment_room(app.doc, params.date)?; + let applied = app.cash.checked_add(app.discount).ok_or_else(|| { + LedgerError::TooLargeToCompute(format!("{}'s settlement", app.doc)) + })?; + if applied > room { + return Err(LedgerError::Overpayment(applied, room)); + } + if let Some(reason) = &app.discount_reason + && (reason.contains(['\n', '\r']) + || reason.chars().count() > arap::MAX_REFERENCE_CHARS) + { + return Err(LedgerError::InvalidDiscountReason( + arap::MAX_REFERENCE_CHARS, + )); + } + if app.discount > Decimal::ZERO { + self.check_discount(app, kind, total, doc_date, params.date)?; + } + cash.push(app.cash); + discounts.push(app.discount); + } + let fee_amount = params.fee.as_ref().map_or(Decimal::ZERO, |f| f.amount); + let settlement = kernel::settle(&cash, &discounts, fee_amount).ok_or_else(|| { + LedgerError::InvalidSettlement( + "the cash and discounts don't cover the applications exactly, or the fee is above the cash" + .into(), + ) + })?; + if settlement.deposit <= Decimal::ZERO { + return Err(LedgerError::InvalidSettlement( + "the processor's fee can't take the whole payment: the deposit would be nothing" + .into(), + )); + } + let any_discount = discounts.iter().any(|d| *d > Decimal::ZERO); + let mut total_discount = Decimal::ZERO; + for app in ¶ms.applications { + if app.discount > Decimal::ZERO { + total_discount = total_discount + .checked_add(app.discount) + .ok_or_else(|| LedgerError::TooLargeToCompute("the discounts".into()))?; + } + } + let discount_account = if any_discount { + Some(self.role_account(&discount_role).ok_or_else(|| { + LedgerError::InvalidSettlement(format!( + "{} has no account in these books yet", + discount_role.role + )) + })?) + } else { + None + }; + let fee_account = match ¶ms.fee { + None => None, + Some(fee) => Some(match fee.account { + Some(a) => { + self.check_line_account(a)?; + let account = self.chart.get(a).expect("check_line_account passed"); + if account.account_type != AccountType::Expense || account.placeholder { + return Err(LedgerError::InvalidFee(format!( + "{} {} is not an expense account: the fee goes to an expense", + account.code, account.name + ))); + } + a + } + None => { + self.check_controls(&[PAYMENT_PROCESSING_FEES])?; + self.role_account(&PAYMENT_PROCESSING_FEES).ok_or_else(|| { + LedgerError::InvalidSettlement( + "payment_processing_fees has no account in these books yet".into(), + ) + })? + } + }), + }; + + let (control_side, discount_side, cash_side) = match kind { + arap::DocumentKind::Invoice => (Side::Credit, Side::Debit, Side::Debit), + arap::DocumentKind::Bill => (Side::Debit, Side::Credit, Side::Credit), + }; + let mut rows: Vec = + Vec::with_capacity(2 * params.applications.len() + 3); + for (app, cash) in params.applications.iter().zip(cash.iter()) { + rows.push(( + control_account, + control_side, + *cash, + Some(DocTag { + doc: app.doc, + movement: Movement::Payment, + }), + )); + } + for app in ¶ms.applications { + if app.discount > Decimal::ZERO { + rows.push(( + control_account, + control_side, + app.discount, + Some(DocTag { + doc: app.doc, + movement: Movement::Discount, + }), + )); + } + } + if let Some(fee) = ¶ms.fee { + rows.push(( + fee_account.expect("a fee has its account"), + Side::Debit, + fee.amount, + None, + )); + } + if let Some(account) = discount_account { + rows.push((account, discount_side, total_discount, None)); + } + rows.push((params.cash_account, cash_side, settlement.deposit, None)); + Ok(rows) + } + + /// Refuse a discount the document's terms don't allow without a reason + /// ([`Self::settlement_lines`] SCENARIO 4), and a discount on a bill + /// with an asset or prepaid line (D-49). + fn check_discount( + &self, + app: &arap::Application, + kind: arap::DocumentKind, + total: Decimal, + doc_date: NaiveDate, + date: NaiveDate, + ) -> Result<(), LedgerError> { + if kind == arap::DocumentKind::Bill + && let arap::DocRef::Bill(id) = app.doc + && let Some(bill) = self.bills.get(&id) + { + for line in &bill.lines { + let account = match line.account { + Some(a) => a, + None => continue, + }; + if self + .chart + .get(account) + .is_some_and(|a| a.account_type == AccountType::Asset) + { + let a = self.chart.get(account).expect("checked above"); + return Err(LedgerError::DiscountOnAssetBill { + doc: app.doc.to_string(), + code: a.code.clone(), + name: a.name.clone(), + }); + } + } + } + let terms = match app.doc { + arap::DocRef::Invoice(id) => self.invoices.get(&id).and_then(|i| i.terms.clone()), + arap::DocRef::Bill(id) => self.bills.get(&id).and_then(|b| b.terms.clone()), + }; + let (max, by) = match terms { + Some(t) => { + let max = t + .discount() + .and_then(|d| kernel::discount_max(total, d.percent_bp)); + (max, t.discount_by(doc_date)) + } + None => (None, None), + }; + let within = max.is_some_and(|m| app.discount <= m) && by.is_some_and(|b| date <= b); + let has_reason = app + .discount_reason + .as_deref() + .is_some_and(|r| !r.trim().is_empty()); + if !within && !has_reason { + let because = match (max, by) { + (None, _) => { + "its terms state no early-payment discount this version can bound".to_string() + } + (Some(m), _) if app.discount > m => { + format!("it is more than the terms' {m}") + } + (Some(_), None) => "its terms state no discount date".to_string(), + (Some(_), Some(b)) => format!("it is after the discount date {b}"), + }; + return Err(LedgerError::DiscountNeedsReason { + doc: app.doc.to_string(), + discount: app.discount, + because, + }); + } + Ok(()) + } + + /// Create the accounts a settlement posts to: the control role, the + /// discount role when a discount is taken, and the fee's default role. + fn ensure_settlement_accounts( + &mut self, + params: &arap::SettleParams, + ) -> Result<(), LedgerError> { + let kind = match params.applications.first().map(|a| a.doc) { + Some(arap::DocRef::Bill(_)) => arap::DocumentKind::Bill, + _ => arap::DocumentKind::Invoice, + }; + let control = match kind { + arap::DocumentKind::Invoice => RECEIVABLES, + arap::DocumentKind::Bill => PAYABLES, + }; + self.check_controls(&[control])?; + self.ensure_control(&control); + if params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + { + let discount_role = match kind { + arap::DocumentKind::Invoice => SALES_ALLOWANCES, + arap::DocumentKind::Bill => PURCHASE_DISCOUNTS, + }; + self.check_controls(&[discount_role])?; + self.ensure_control(&discount_role); + } + if let Some(fee) = ¶ms.fee + && fee.account.is_none() + { + self.check_controls(&[PAYMENT_PROCESSING_FEES])?; + self.ensure_control(&PAYMENT_PROCESSING_FEES); + } + Ok(()) + } + + /// [`Self::settle`]'s steps, run inside [`Self::atomically`]. + fn settle_steps(&mut self, params: arap::SettleParams) -> Result { + self.ensure_settlement_accounts(¶ms)?; + let rows = self.settlement_lines(¶ms)?; + let description = if params.description.trim().is_empty() { + format!( + "Settlement of {} document{}", + params.applications.len(), + if params.applications.len() == 1 { + "" + } else { + "s" + } + ) + } else { + params.description.clone() + }; + let entry = self.create_entry(params.date, &description); + for (account, side, amount, tag) in &rows { + let money = Money::new(*amount, self.base_currency); + match tag { + Some(tag) => self.add_tagged_line(entry, *account, *side, money, *tag)?, + None => self.add_line(entry, *account, *side, money)?, + } + } + self.post_entry(entry)?; + match params.applications[0].doc { + arap::DocRef::Invoice(_) => self.tag_customer(entry, params.party), + arap::DocRef::Bill(_) => self.tag_vendor(entry, params.party), + } + if params.instrument.is_some() { + self.set_entry_instrument(entry, params.instrument)?; + } + for app in ¶ms.applications { + self.payment_events + .entry(app.doc) + .or_default() + .push(app.cash); + self.refresh_doc(app.doc); + } + Ok(entry) + } + /// Record a customer payment against an invoice. Posts: Dr Cash, /// Cr A/R ([`Self::receive_payment_with`], with no payment method or /// reference). @@ -6276,14 +6713,25 @@ impl Ledger { cash_account: AccountId, details: &arap::PaymentDetails, ) -> Result { - self.atomically(|l| { - l.payment_steps( - arap::DocRef::Invoice(invoice_id), - date, - amount, - cash_account, - details, - ) + let customer = self + .invoices + .get(&invoice_id) + .ok_or(LedgerError::UnknownInvoice(invoice_id))? + .customer_id; + self.settle(arap::SettleParams { + date, + description: details.description(&format!("Payment on invoice {invoice_id}")), + cash_account, + party: customer, + applications: vec![arap::Application { + doc: arap::DocRef::Invoice(invoice_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), }) } @@ -6335,100 +6783,28 @@ impl Ledger { cash_account: AccountId, details: &arap::PaymentDetails, ) -> Result { - self.atomically(|l| { - l.payment_steps( - arap::DocRef::Bill(bill_id), - date, - amount, - cash_account, - details, - ) + let vendor = self + .bills + .get(&bill_id) + .ok_or(LedgerError::UnknownBill(bill_id))? + .vendor_id; + self.settle(arap::SettleParams { + date, + description: details.description(&format!("Payment on bill {bill_id}")), + cash_account, + party: vendor, + applications: vec![arap::Application { + doc: arap::DocRef::Bill(bill_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), }) } - /// The steps of a payment ([`Self::receive_payment_with`], - /// [`Self::pay_bill_with`]), run inside [`Self::atomically`]. - fn payment_steps( - &mut self, - doc: arap::DocRef, - date: NaiveDate, - amount: Decimal, - account: AccountId, - details: &arap::PaymentDetails, - ) -> Result { - if amount <= Decimal::ZERO { - return Err(LedgerError::InvalidPaymentAmount(amount)); - } - let (doc_date, ..) = self.doc_header(doc)?; - let room = self.payment_room(doc, date)?; - if amount > room { - return Err(LedgerError::Overpayment(amount, room)); - } - if let Some(pid) = self.closed_period_containing(date) { - return Err(LedgerError::ClosedPeriod(pid, date.to_string())); - } - if date < doc_date { - return Err(LedgerError::PaymentBeforeDocument { - date, - document_date: doc_date, - }); - } - details.check()?; - if let Some(i) = details.instrument - && !self.instruments.contains_key(&i) - { - return Err(LedgerError::UnknownInstrument(i)); - } - let role = match doc { - arap::DocRef::Invoice(_) => RECEIVABLES, - arap::DocRef::Bill(_) => PAYABLES, - }; - self.check_controls(&[role])?; - // Every check before the entry exists: a refusal leaves no draft. - self.check_payment_account(account)?; - let control = self.ensure_control(&role); - let money = Money::new(amount, self.base_currency); - let tag = arap::DocTag { - doc, - movement: arap::Movement::Payment, - }; - let entry = match doc { - arap::DocRef::Invoice(id) => { - let entry = self.create_entry( - date, - &details.description(&format!("Payment on invoice {id}")), - ); - self.add_line(entry, account, Side::Debit, money)?; - self.add_tagged_line(entry, control, Side::Credit, money, tag)?; - entry - } - arap::DocRef::Bill(id) => { - let entry = - self.create_entry(date, &details.description(&format!("Payment on bill {id}"))); - self.add_tagged_line(entry, control, Side::Debit, money, tag)?; - self.add_line(entry, account, Side::Credit, money)?; - entry - } - }; - self.post_entry(entry)?; - match doc { - arap::DocRef::Invoice(id) => { - let customer = self.invoices[&id].customer_id; - self.tag_customer(entry, customer); - } - arap::DocRef::Bill(id) => { - let vendor = self.bills[&id].vendor_id; - self.tag_vendor(entry, vendor); - } - } - if details.instrument.is_some() { - self.set_entry_instrument(entry, details.instrument)?; - } - self.payment_events.entry(doc).or_default().push(amount); - self.refresh_doc(doc); - Ok(entry) - } - /// Void a document: reverse its own entry (B-9), described "Void /// invoice 7: reason". It keeps its id (and, later, its number); its /// status as of the void's date on is Void. Plain @@ -6741,6 +7117,35 @@ impl Ledger { } } + /// Tag the line a new payment event names (plan P4b S6; rules.md B-42 + /// INVARIANT 3): line `index` of `entry`, when it is on the role's + /// account, on the side the movement posts, untagged, and for `amount`. + /// Otherwise it stays untagged and the control check lists it; replay + /// never refuses (B-28). + fn tag_replayed_at( + &mut self, + entry: EntryId, + role: &ControlAccount, + index: usize, + amount: Decimal, + tag: arap::DocTag, + ) { + let (Some(account), Some(e)) = (self.role_account(role), self.entries.get(&entry)) else { + return; + }; + let side = match tag.movement { + arap::Movement::Origin => tag.doc.owed_side(), + _ => kernel::flip(tag.doc.owed_side()), + }; + let matches = e + .lines() + .get(index) + .is_some_and(|l| l.account == account && l.side == side && l.amount.amount == amount); + if matches && !self.line_docs.contains_key(&(entry, index)) { + self.tag_line(entry, index, tag); + } + } + /// Record an invoice without posting: replay support for journaling /// stores. The invoice's entry was already rebuilt from its own /// `entry.created` / `entry.line_added` / `entry.posted` events; this @@ -6917,6 +7322,24 @@ impl Ledger { invoice_id: InvoiceId, amount: Decimal, entry_id: EntryId, + ) -> Result<(), LedgerError> { + self.replay_invoice_payment_at(invoice_id, amount, entry_id, None, Decimal::ZERO, None) + } + + /// [`Self::replay_invoice_payment`] with the line a new event names and + /// the discount it took (plan P4b S6; rules.md B-42 INVARIANT 3). A + /// line the event names but that can't be matched (no such line, the + /// wrong account or side, or already tagged) stays untagged, and the + /// control check lists it: replay never refuses (B-28). + #[doc(hidden)] + pub fn replay_invoice_payment_at( + &mut self, + invoice_id: InvoiceId, + amount: Decimal, + entry_id: EntryId, + line: Option, + discount: Decimal, + discount_line: Option, ) -> Result<(), LedgerError> { let customer = self .invoices @@ -6924,15 +7347,30 @@ impl Ledger { .ok_or(LedgerError::UnknownInvoice(invoice_id))? .customer_id; let doc = arap::DocRef::Invoice(invoice_id); - self.tag_replayed( - entry_id, - &RECEIVABLES, - Some(amount), - arap::DocTag { - doc, - movement: arap::Movement::Payment, - }, - ); + let payment = arap::DocTag { + doc, + movement: arap::Movement::Payment, + }; + match line { + Some(line) => { + self.tag_replayed_at(entry_id, &RECEIVABLES, line, amount, payment); + } + None => self.tag_replayed(entry_id, &RECEIVABLES, Some(amount), payment), + } + if discount > Decimal::ZERO + && let Some(line) = discount_line + { + self.tag_replayed_at( + entry_id, + &RECEIVABLES, + line, + discount, + arap::DocTag { + doc, + movement: arap::Movement::Discount, + }, + ); + } self.tag_customer(entry_id, customer); self.payment_events.entry(doc).or_default().push(amount); self.refresh_doc(doc); @@ -6947,6 +7385,21 @@ impl Ledger { bill_id: BillId, amount: Decimal, entry_id: EntryId, + ) -> Result<(), LedgerError> { + self.replay_bill_payment_at(bill_id, amount, entry_id, None, Decimal::ZERO, None) + } + + /// [`Self::replay_bill_payment`] with the line a new event names and + /// the discount it took; as [`Self::replay_invoice_payment_at`]. + #[doc(hidden)] + pub fn replay_bill_payment_at( + &mut self, + bill_id: BillId, + amount: Decimal, + entry_id: EntryId, + line: Option, + discount: Decimal, + discount_line: Option, ) -> Result<(), LedgerError> { let vendor = self .bills @@ -6954,15 +7407,30 @@ impl Ledger { .ok_or(LedgerError::UnknownBill(bill_id))? .vendor_id; let doc = arap::DocRef::Bill(bill_id); - self.tag_replayed( - entry_id, - &PAYABLES, - Some(amount), - arap::DocTag { - doc, - movement: arap::Movement::Payment, - }, - ); + let payment = arap::DocTag { + doc, + movement: arap::Movement::Payment, + }; + match line { + Some(line) => { + self.tag_replayed_at(entry_id, &PAYABLES, line, amount, payment); + } + None => self.tag_replayed(entry_id, &PAYABLES, Some(amount), payment), + } + if discount > Decimal::ZERO + && let Some(line) = discount_line + { + self.tag_replayed_at( + entry_id, + &PAYABLES, + line, + discount, + arap::DocTag { + doc, + movement: arap::Movement::Discount, + }, + ); + } self.tag_vendor(entry_id, vendor); self.payment_events.entry(doc).or_default().push(amount); self.refresh_doc(doc); @@ -7044,6 +7512,69 @@ impl Ledger { Ok(rows) } + /// One row per open document in an aging, as of `as_of` (plan P4b S6, + /// 8.3.10): the same figures the party rows sum, with the document's + /// own number, dates, terms and bucket. Invoices (`invoices` true) or + /// bills; due date first. + pub fn aging_documents( + &self, + as_of: NaiveDate, + invoices: bool, + ) -> Result, LedgerError> { + let mut out = Vec::new(); + for doc in self.documents() { + let (name, date, due_date, terms) = match doc { + arap::DocRef::Invoice(id) if invoices => { + let i = &self.invoices[&id]; + ( + self.customers.get(&i.customer_id).map(|c| c.name.clone()), + i.date, + i.due_date, + i.terms.as_ref().map(|t| t.describe()), + ) + } + arap::DocRef::Bill(id) if !invoices => { + let b = &self.bills[&id]; + ( + self.vendors.get(&b.vendor_id).map(|v| v.name.clone()), + b.date, + b.due_date, + b.terms.as_ref().map(|t| t.describe()), + ) + } + _ => continue, + }; + let Some(st) = self.doc_state(doc, as_of)? else { + continue; + }; + if st.outstanding <= Decimal::ZERO { + continue; + } + out.push(arap::AgingDocument { + doc, + party: name.unwrap_or_default(), + date, + due_date, + terms, + total: st.total, + paid: st.paid, + discounted: st.discounted, + outstanding: st.outstanding, + bucket: arap::bucket_key(due_date, as_of), + days_overdue: st.overdue_days, + }); + } + out.sort_by_key(|d| { + ( + d.due_date, + match d.doc { + arap::DocRef::Invoice(id) | arap::DocRef::Bill(id) => id, + }, + ) + }); + Ok(out) + } + // ------------------------------------------------------------------ // Bank reconciliation // ------------------------------------------------------------------ diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index a52639e..cdc54ea 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -48,10 +48,11 @@ pub mod tax_capital; pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side}; pub use arap::{ - AgingRow, Bill, BillFields, BillId, ControlCheck, Customer, CustomerId, DocLine, DocRef, - DocState, DocStatus, DocTag, DocumentKind, DocumentLine, DocumentPartKey, DocumentStatus, - EarlyDiscount, Invoice, InvoiceFields, InvoiceId, Movement, PartySnapshot, PaymentDetails, - SendKind, Sent, SentVia, Terms, TermsKind, UnattachedLine, Vendor, VendorId, + AgingDocument, AgingRow, Application, Bill, BillFields, BillId, ControlCheck, Customer, + CustomerId, DocLine, DocRef, DocState, DocStatus, DocTag, DocumentKind, DocumentLine, + DocumentPartKey, DocumentStatus, EarlyDiscount, Fee, Invoice, InvoiceFields, InvoiceId, + Movement, PartySnapshot, PaymentDetails, SendKind, Sent, SentVia, SettleParams, SettlementRow, + Terms, TermsKind, UnattachedLine, Vendor, VendorId, }; pub use assets::{ Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod, @@ -76,8 +77,9 @@ pub use instruments::{Instrument, InstrumentId, InstrumentKind}; pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use kernel::{EntryFlows, Flow}; pub use ledger::{ - ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS, - REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts, + ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYMENT_PROCESSING_FEES, PAYROLL, + PURCHASE_DISCOUNTS, RECEIVABLES, RETAINED_EARNINGS, REVENUE, ReconciliationReport, + SALES_ALLOWANCES, UnclearedLine, control_account, control_accounts, }; pub use money::{Currency, Money}; pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts}; diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index b7929f2..7bb8ce9 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -1928,6 +1928,38 @@ fn discount_max_is_bounded() { kani::cover!(got.is_none(), "an exact bound finer than a whole cent"); } +/// `kernel::settle` is exact: the applications' total is the cash plus the +/// discounts, the deposit is the cash less the fee, and a fee above the +/// cash is refused rather than wrapped. +#[kani::proof] +fn settle_is_exact() { + const N: usize = 3; + let cash: [i32; N] = kani::any(); + let discounts: [i32; N] = kani::any(); + let fee: i32 = kani::any(); + kani::assume(fee >= 0 && fee <= 3_000_000); + for i in 0..N { + kani::assume(cash[i] >= 0 && cash[i] <= 1_000_000); + kani::assume(discounts[i] >= 0 && discounts[i] <= 1_000_000); + } + let total_cash = cash[0] + cash[1] + cash[2]; + let total_discounts = discounts[0] + discounts[1] + discounts[2]; + let got = kernel::settle(&cash, &discounts, fee); + if fee <= total_cash { + let settlement = got.expect("no overflow under these bounds"); + assert_eq!(settlement.applications_total, total_cash + total_discounts); + assert_eq!(settlement.deposit, total_cash - fee); + assert!(settlement.deposit >= 0); + } else { + assert!(got.is_none()); + } + kani::cover!( + got.is_some() && discounts.iter().any(|d| *d > 0) && fee > 0 && fee < total_cash, + "a discount and a fee inside the cash" + ); + kani::cover!(got.is_none(), "a fee above the cash"); +} + /// `kernel::line_amount` is the exact product of a quantity and a unit /// price: never a rounded amount, and None rather than an overflow. #[kani::proof] diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index 0f0c14a..389a472 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -521,6 +521,27 @@ proptest! { ); } + /// kernel::settle at i64 (what Kani's settle_is_exact proved) and at + /// Decimal agree: the same cash, discounts and fee give the same + /// applications total and deposit, and both refuse the same cases. + #[test] + fn settle_agrees( + cash in prop::collection::vec(0..=MAX_CENTS, 0..4), + discounts in prop::collection::vec(0..=MAX_CENTS, 0..4), + fee in 0..=MAX_CENTS, + ) { + let narrow = kernel::settle(&cash, &discounts, fee); + let wide = kernel::settle( + &cash.iter().map(|&c| dec(c)).collect::>(), + &discounts.iter().map(|&c| dec(c)).collect::>(), + dec(fee), + ); + prop_assert_eq!( + narrow.map(|s| (dec(s.applications_total), dec(s.deposit))), + wide.map(|s| (s.applications_total, s.deposit)) + ); + } + /// kernel::line_amount at i64 (what Kani's line_amount_is_exact /// proved) and at Decimal agree: cents x cents at i64 is the same /// product as dollars x dollars at Decimal, to four places. diff --git a/crates/ledger-core/tests/spec_cash_flow.rs b/crates/ledger-core/tests/spec_cash_flow.rs index 57890e5..3874d9d 100644 --- a/crates/ledger-core/tests/spec_cash_flow.rs +++ b/crates/ledger-core/tests/spec_cash_flow.rs @@ -3662,3 +3662,41 @@ fn a_government_selling_services_is_a_supplier() { let line = cf.operating.iter().find(|l| l.entry == stamps).unwrap(); assert_eq!(line.class, Some(OperatingClass::OtherPayments)); } + +/// A bill whose line is equipment, paid later: the payment's cash is +/// investing, through the bill's split (D-35; rules.md B-14), not +/// operating. Plan P4b S6 keeps this true when the bill is paid as part +/// of a settlement. +#[test] +fn equipment_bill_payment_is_investing_cash() { + use ledger_core::arap::{BillFields, DocumentLine}; + let mut b = Books::new(); + b.acct("1000", "Bank", AccountType::Asset); + b.acct("1500", "Equipment", AccountType::Asset); + b.acct("2000", "Accounts Payable", AccountType::Liability); + let bank = b.id("1000"); + let equipment = b.id("1500"); + let vendor = b.l.add_vendor("Tobin Harlow Supply").unwrap(); + let bill = + b.l.create_bill_with( + vendor, + d(2026, 1, 10), + d(2026, 2, 9), + vec![DocumentLine { + description: "Laptop".into(), + amount: x("500.00"), + class_id: None, + account: Some(equipment), + quantity: None, + unit_price: None, + }], + BillFields::default(), + ) + .unwrap(); + b.l.pay_bill(bill, d(2026, 1, 20), x("500.00"), bank) + .unwrap(); + let cf = b.cf(d(2026, 1, 1), d(2026, 1, 31), &["1000"], &[], &["1500"]); + assert_eq!(cf.cash_from_investing, -x("500.00")); + assert_eq!(cf.cash_from_operations, Decimal::ZERO); + assert_eq!(cf.net_change_in_cash, -x("500.00")); +} diff --git a/crates/ledger-core/tests/spec_documents.rs b/crates/ledger-core/tests/spec_documents.rs index 1e02949..56a2f06 100644 --- a/crates/ledger-core/tests/spec_documents.rs +++ b/crates/ledger-core/tests/spec_documents.rs @@ -1922,3 +1922,380 @@ fn duplicate_vendor_ref_refused_unless_a_reason_is_given() { ) .unwrap(); } + +// --------------------------------------------------------------------------- +// P4b S6: paying on terms (rules.md B-42) +// --------------------------------------------------------------------------- + +/// A bill on "2/10 net 30", paid within the discount window: the gross +/// method's entry is Dr A/P the full amount, Cr purchase discounts what +/// was taken, Cr the bank the rest; the bill reads paid and discounted. +/// A discount larger than the terms allow needs a reason (B-42). +#[test] +fn bill_on_terms_with_discount() { + use ledger_core::{Application, DocRef, SettleParams}; + let mut b = books(); + let terms = Terms::parse("2/10 net 30").unwrap(); + let on = d(2026, 4, 1); + let bill = + b.l.create_bill_with( + b.vendor, + on, + terms.due_date(on).unwrap(), + line("Paper", 10_000), + BillFields { + terms: Some(terms), + ..BillFields::default() + }, + ) + .unwrap(); + let entry = + b.l.settle(SettleParams { + date: d(2026, 4, 5), + description: "Bill 1, early".into(), + cash_account: b.bank, + party: b.vendor, + applications: vec![Application { + doc: DocRef::Bill(bill), + cash: cents(9_800), + discount: cents(200), + discount_reason: None, + }], + fee: None, + instrument: None, + reference: None, + }) + .unwrap(); + let lines = b.l.get_entry(entry).unwrap().lines(); + assert_eq!(lines.len(), 4); + assert_eq!( + (lines[0].account, lines[0].side), + (b.account("2000"), Side::Debit) + ); + assert_eq!(lines[0].amount.amount, cents(9_800)); + assert_eq!( + (lines[1].account, lines[1].side), + (b.account("2000"), Side::Debit) + ); + assert_eq!(lines[1].amount.amount, cents(200)); + assert_eq!( + (lines[2].account, lines[2].side), + (b.account("6995"), Side::Credit) + ); + assert_eq!(lines[2].amount.amount, cents(200)); + assert_eq!((lines[3].account, lines[3].side), (b.bank, Side::Credit)); + assert_eq!(lines[3].amount.amount, cents(9_800)); + let st = b.state(DocRef::Bill(bill), d(2026, 4, 5)); + assert_eq!( + (st.status, st.paid, st.discounted, st.outstanding), + (DocStatus::Paid, cents(9_800), cents(200), Decimal::ZERO) + ); + assert_eq!(b.l.line_doc(entry, 0).unwrap().movement, Movement::Payment); + assert_eq!(b.l.line_doc(entry, 1).unwrap().movement, Movement::Discount); + b.ties(d(2026, 4, 5)); + + // 3% off is more than 2/10 allows: refused without a reason, taken + // with one (a vendor honouring it anyway). + let bill2 = + b.l.create_bill_with( + b.vendor, + on, + Terms::parse("2/10 net 30").unwrap().due_date(on).unwrap(), + line("Paper", 10_000), + BillFields { + terms: Some(Terms::parse("2/10 net 30").unwrap()), + ..BillFields::default() + }, + ) + .unwrap(); + let params = |reason: Option| SettleParams { + date: d(2026, 4, 6), + description: "Bill 2, late".into(), + cash_account: b.bank, + party: b.vendor, + applications: vec![Application { + doc: DocRef::Bill(bill2), + cash: cents(9_700), + discount: cents(300), + discount_reason: reason, + }], + fee: None, + instrument: None, + reference: None, + }; + assert!(matches!( + b.l.settle(params(None)), + Err(LedgerError::DiscountNeedsReason { .. }) + )); + b.l.settle(params(Some("the vendor honoured 3%".into()))) + .unwrap(); +} + +/// A discount taken after the discount date needs a reason (B-42 +/// INVARIANT 2); a bill with an asset or prepaid line refuses one with the +/// D-49 remedy, and none of the refusals changes the books. +#[test] +fn late_discount_needs_a_reason() { + use ledger_core::{Application, DocRef, SettleParams}; + let mut b = books(); + let equipment = + b.l.add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let terms = Terms::parse("2/10 net 30").unwrap(); + let on = d(2026, 4, 1); + let bill = + b.l.create_bill_with( + b.vendor, + on, + terms.due_date(on).unwrap(), + line("Paper", 10_000), + BillFields { + terms: Some(terms.clone()), + ..BillFields::default() + }, + ) + .unwrap(); + let late = |reason: Option| SettleParams { + date: d(2026, 4, 20), + description: "Bill 1, late discount".into(), + cash_account: b.bank, + party: b.vendor, + applications: vec![Application { + doc: DocRef::Bill(bill), + cash: cents(9_800), + discount: cents(200), + discount_reason: reason, + }], + fee: None, + instrument: None, + reference: None, + }; + assert!(matches!( + b.l.settle(late(None)), + Err(LedgerError::DiscountNeedsReason { .. }) + )); + b.l.settle(late(Some("the vendor honoured it late".into()))) + .unwrap(); + + // A bill whose line is equipment: no apportioning (D-49). + let asset_bill = + b.l.create_bill_with( + b.vendor, + on, + terms.due_date(on).unwrap(), + vec![DocumentLine { + description: "Laptop".into(), + amount: cents(10_000), + class_id: None, + account: Some(equipment), + quantity: None, + unit_price: None, + }], + BillFields { + terms: Some(terms), + ..BillFields::default() + }, + ) + .unwrap(); + let before = b.l.doc_state(DocRef::Bill(asset_bill), d(2026, 4, 5)); + let refused = b.l.settle(SettleParams { + date: d(2026, 4, 5), + description: "Laptop, discounted".into(), + cash_account: b.bank, + party: b.vendor, + applications: vec![Application { + doc: DocRef::Bill(asset_bill), + cash: cents(9_800), + discount: cents(200), + discount_reason: Some("vendor honoured it".into()), + }], + fee: None, + instrument: None, + reference: None, + }); + match refused { + Err(LedgerError::DiscountOnAssetBill { code, .. }) => assert_eq!(code, "1500"), + other => panic!("expected DiscountOnAssetBill, got {other:?}"), + } + assert_eq!( + b.l.doc_state(DocRef::Bill(asset_bill), d(2026, 4, 5)), + before + ); + assert_eq!( + b.state(DocRef::Bill(asset_bill), d(2026, 4, 5)).outstanding, + cents(10_000) + ); +} + +/// The processor's fee posts gross (A-4): Dr the bank the net, Dr the +/// processing-fee expense the fee, Cr A/R the gross; the invoice reads +/// paid and the deposit account has only the net. +#[test] +fn a_fee_posts_gross_to_receivables_and_net_to_the_bank() { + use ledger_core::{Application, DocRef, Fee, SettleParams}; + let mut b = books(); + let invoice = b.invoice(d(2026, 4, 1), 10_000); + let entry = + b.l.settle(SettleParams { + date: d(2026, 4, 3), + description: "Card payment, fee withheld".into(), + cash_account: b.bank, + party: b.customer, + applications: vec![Application { + doc: DocRef::Invoice(invoice), + cash: cents(10_000), + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: Some(Fee { + amount: cents(300), + account: None, + vendor: None, + }), + instrument: None, + reference: None, + }) + .unwrap(); + let lines = b.l.get_entry(entry).unwrap().lines(); + assert_eq!(lines.len(), 3); + assert_eq!( + (lines[0].account, lines[0].side), + (b.account("1200"), Side::Credit) + ); + assert_eq!(lines[0].amount.amount, cents(10_000)); + assert_eq!( + (lines[1].account, lines[1].side), + (b.account("6060"), Side::Debit) + ); + assert_eq!(lines[1].amount.amount, cents(300)); + assert_eq!((lines[2].account, lines[2].side), (b.bank, Side::Debit)); + assert_eq!(lines[2].amount.amount, cents(9_700)); + assert_eq!( + b.state(DocRef::Invoice(invoice), d(2026, 4, 3)).status, + DocStatus::Paid + ); + assert_eq!(b.l.account_balance(b.bank).unwrap(), cents(9_700)); +} + +/// A payment that settles more than one document names its lines in +/// application order, and two applications on one document are refused: +/// each document's bound is one bound (B-42 INVARIANTS 1 and 2). +#[test] +fn a_settlement_names_its_lines_and_refuses_a_document_twice() { + use ledger_core::{Application, DocRef, SettleParams}; + let mut b = books(); + let first = b.invoice(d(2026, 4, 1), 10_000); + let second = b.invoice(d(2026, 4, 2), 5_000); + let entry = + b.l.settle(SettleParams { + date: d(2026, 4, 5), + description: "Two invoices, one deposit".into(), + cash_account: b.bank, + party: b.customer, + applications: vec![ + Application { + doc: DocRef::Invoice(first), + cash: cents(10_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + Application { + doc: DocRef::Invoice(second), + cash: cents(5_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: None, + instrument: None, + reference: None, + }) + .unwrap(); + let lines = b.l.get_entry(entry).unwrap().lines(); + assert_eq!(lines.len(), 3); + assert_eq!(b.l.line_doc(entry, 0).unwrap().doc, DocRef::Invoice(first)); + assert_eq!(b.l.line_doc(entry, 1).unwrap().doc, DocRef::Invoice(second)); + assert!(b.l.line_doc(entry, 2).is_none()); + assert_eq!(b.l.account_balance(b.bank).unwrap(), cents(15_000)); + + let thrice = b.invoice(d(2026, 4, 3), 10_000); + let before = b.l.account_balance(b.bank).unwrap(); + let refused = b.l.settle(SettleParams { + date: d(2026, 4, 5), + description: "Twice".into(), + cash_account: b.bank, + party: b.customer, + applications: vec![ + Application { + doc: DocRef::Invoice(thrice), + cash: cents(5_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + Application { + doc: DocRef::Invoice(thrice), + cash: cents(5_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: None, + instrument: None, + reference: None, + }); + assert!(matches!( + refused, + Err(LedgerError::SettlementDuplicateDocument(_)) + )); + assert_eq!(b.l.account_balance(b.bank).unwrap(), before); +} + +/// A discount on a bill with an asset line is refused, never apportioned +/// (D-49): the refusal names the asset account and both remedies (record +/// the bill net, or reclassify Dr 6995 / Cr the asset). +#[test] +fn discount_on_an_asset_bill_is_refused_with_the_remedy() { + use ledger_core::{Application, DocRef, SettleParams}; + let mut b = books(); + let equipment = + b.l.add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let bill = + b.l.create_bill( + b.vendor, + d(2026, 4, 1), + d(2026, 5, 1), + vec![DocumentLine { + description: "Laptop".into(), + amount: cents(10_000), + class_id: None, + account: Some(equipment), + quantity: None, + unit_price: None, + }], + ) + .unwrap(); + match b.l.settle(SettleParams { + date: d(2026, 4, 5), + description: "Laptop, discounted".into(), + cash_account: b.bank, + party: b.vendor, + applications: vec![Application { + doc: DocRef::Bill(bill), + cash: cents(9_800), + discount: cents(200), + discount_reason: Some("the vendor honoured it".into()), + }], + fee: None, + instrument: None, + reference: None, + }) { + Err(LedgerError::DiscountOnAssetBill { code, .. }) => assert_eq!(code, "1500"), + other => panic!("expected DiscountOnAssetBill, got {other:?}"), + } + assert_eq!( + b.state(DocRef::Bill(bill), d(2026, 4, 5)).outstanding, + cents(10_000) + ); + b.ties(d(2026, 4, 5)); +} 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 0709afc..2263661 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -157,6 +157,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 Result { - Ok(ledger_core::PaymentDetails { - instrument: p - .instrument - .as_deref() - .map(|r| resolve_instrument(store, r)) - .transpose()?, - reference: p.reference.clone(), - }) -} - -/// What a payment tool answers: the document's figures after it, derived -/// from its lines that stand (rules.md B-38), as of the end of the books. -fn payment_out( - ledger: &Ledger, - doc: ledger_core::DocRef, - entry_id: u64, -) -> Result, String> { - let st = ledger - .doc_state(doc, chrono::NaiveDate::MAX) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("{} not found", doc_label(doc)))?; - Ok(Json(PaymentOut { - entry_id, - remaining: money(st.outstanding), - status: st.status.key().to_string(), - })) -} - /// A document's id. fn doc_id(doc: ledger_core::DocRef) -> u64 { match doc { @@ -71,14 +36,6 @@ fn movement_key(m: ledger_core::Movement) -> &'static str { } } -/// A document's kind and id for people ("invoice 7"). -fn doc_label(doc: ledger_core::DocRef) -> String { - match doc { - ledger_core::DocRef::Invoice(id) => format!("invoice {id}"), - ledger_core::DocRef::Bill(id) => format!("bill {id}"), - } -} - /// An invoice's or bill's lines as the tool takes them: each amount /// parsed, each project and account resolved ([`resolve_class`], /// [`resolve_account`]), and quantity and unit price together, before @@ -847,74 +804,6 @@ impl BooksServer { })) } - #[tool( - name = "books.receive_payment", - title = "Receive an invoice payment", - description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R, the receivable line tagged with the invoice (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments) and the processor's or bank's reference when known. The books record payments; they never collect them (D-45). Refused above what it owes, before its date, or into an account that isn't cash.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn receive_payment( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let cash = resolve_account(&store, &p.cash_account)?; - let details = payment_details(&store, &p)?; - let entry_id = store - .receive_payment_with( - p.document_id, - parse_date(&p.date)?, - parse_amount(&p.amount)?, - cash, - &details, - ) - .map_err(|e| e.to_string())?; - payment_out( - store.ledger(), - ledger_core::DocRef::Invoice(p.document_id), - entry_id, - ) - } - - #[tool( - name = "books.pay_bill", - title = "Pay a bill", - description = "Record a payment of a vendor bill. Posts Dr A/P / Cr Cash, the payable line tagged with the bill (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments: a card payment is left out of 1099-NEC) and the payment's reference when known. The books record payments; they never make them (D-45). Refused above what it owes, before its date, or from an account that isn't cash.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn pay_bill( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let cash = resolve_account(&store, &p.cash_account)?; - let details = payment_details(&store, &p)?; - let entry_id = store - .pay_bill_with( - p.document_id, - parse_date(&p.date)?, - parse_amount(&p.amount)?, - cash, - &details, - ) - .map_err(|e| e.to_string())?; - payment_out( - store.ledger(), - ledger_core::DocRef::Bill(p.document_id), - entry_id, - ) - } - #[tool( name = "books.void_document", title = "Void an invoice or bill", @@ -1229,78 +1118,4 @@ impl BooksServer { let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; Ok(Json(control_check_out(ledger, as_of, &checks))) } - - #[tool( - name = "books.ar_aging", - title = "Receivables aging", - description = described( - "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+).", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn ar_aging(&self, params: Parameters) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; - let rows = store - .ledger() - .ar_aging(as_of) - .map_err(|e| e.to_string())? - .into_iter() - .map(|r| { - let total = r.total().map_err(|e| e.to_string())?.to_string(); - Ok(AgingRowOut { - name: r.name, - current: money(r.current), - days_30: money(r.days_30), - days_60: money(r.days_60), - days_90: money(r.days_90), - days_90_plus: money(r.days_90_plus), - total, - }) - }) - .collect::>()?; - Ok(Json(AgingOut { - as_of: as_of.to_string(), - rows, - })) - } - - #[tool( - name = "books.ap_aging", - title = "Payables aging", - description = described( - "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+).", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn ap_aging(&self, params: Parameters) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; - let rows = store - .ledger() - .ap_aging(as_of) - .map_err(|e| e.to_string())? - .into_iter() - .map(|r| { - let total = r.total().map_err(|e| e.to_string())?.to_string(); - Ok(AgingRowOut { - name: r.name, - current: money(r.current), - days_30: money(r.days_30), - days_60: money(r.days_60), - days_90: money(r.days_90), - days_90_plus: money(r.days_90_plus), - total, - }) - }) - .collect::>()?; - Ok(Json(AgingOut { - as_of: as_of.to_string(), - rows, - })) - } } diff --git a/crates/ledger-mcp/src/tools/reports.rs b/crates/ledger-mcp/src/tools/reports.rs index 3d9fe83..5eb7965 100644 --- a/crates/ledger-mcp/src/tools/reports.rs +++ b/crates/ledger-mcp/src/tools/reports.rs @@ -848,7 +848,7 @@ impl BooksServer { #[tool( name = "books.export", title = "Export a report", - description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), 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 (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).", + description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), 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 (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).", annotations(read_only_hint = true, open_world_hint = false) )] async fn export(&self, params: Parameters) -> Result, String> { @@ -911,9 +911,11 @@ impl BooksServer { "fixed_asset_register" => { self.export_fixed_asset_register(&format, p.as_of.as_deref())? } + "ar_aging" => self.export_aging(&format, true, p.as_of.as_deref())?, + "ap_aging" => self.export_aging(&format, false, p.as_of.as_deref())?, _ => { return Err(format!( - "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, or fixed_asset_register", + "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, fixed_asset_register, ar_aging, or ap_aging", p.report )); } @@ -1205,14 +1207,64 @@ impl BooksServer { .map_err(|e| e.to_string())?; report::members_equity(&me) } + "ar_aging" => { + ledger_package::aging_statement(ledger, true, self.as_of(p.as_of.as_deref())?) + .map_err(|e| e.to_string())? + } + "ap_aging" => { + ledger_package::aging_statement(ledger, false, self.as_of(p.as_of.as_deref())?) + .map_err(|e| e.to_string())? + } _ => { return Err(format!( - "bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, or tax_summary" + "bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, tax_summary, ar_aging, or ap_aging" )); } }) } + /// The receivables or payables aging as CSV or JSON (plan P4b S6, + /// 8.3.10): one row per open document, then the tie-out line; the + /// same CSV the CPA package carries. + fn export_aging( + &self, + format: &str, + invoices: bool, + as_of: Option<&str>, + ) -> Result { + let as_of = self.as_of(as_of)?; + let store = self.lock()?; + if format == "json" { + let docs = store + .ledger() + .aging_documents(as_of, invoices) + .map_err(|e| e.to_string())?; + let rows: Vec = docs + .iter() + .map(|d| { + serde_json::json!({ + "number": match d.doc { + ledger_core::DocRef::Invoice(id) => store.ledger().get_invoice(id).and_then(|i| i.number.clone()), + ledger_core::DocRef::Bill(id) => store.ledger().get_bill(id).and_then(|b| b.vendor_ref.clone()), + }, + "party": d.party, + "date": d.date.to_string(), + "due_date": d.due_date.to_string(), + "terms": d.terms, + "total": money(d.total), + "paid": money(d.paid), + "discounted": money(d.discounted), + "outstanding": money(d.outstanding), + "bucket": d.bucket, + "days_overdue": d.days_overdue, + }) + }) + .collect(); + return serde_json::to_string_pretty(&rows).map_err(|e| e.to_string()); + } + ledger_package::aging_csv(store.ledger(), invoices, as_of) + } + /// Render a report to XLSX bytes via ledger-import. fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result, String> { use ledger_import::{XlsxReport, report_to_xlsx}; diff --git a/crates/ledger-mcp/src/tools/settlements.rs b/crates/ledger-mcp/src/tools/settlements.rs new file mode 100644 index 0000000..90ca18e --- /dev/null +++ b/crates/ledger-mcp/src/tools/settlements.rs @@ -0,0 +1,557 @@ +//! `books.receive_payment`, `books.pay_bill`, `books.bills_due` and the +//! agings: paying on terms (plan P4b S6; rules.md B-42). A payment settles +//! one or several invoices (or bills) of one party, with the early-payment +//! discounts their terms allow and the processor's fee on a customer +//! payment posted gross. Split from `tools/documents.rs` so S4.4's work +//! there and this slice's do not collide (plan/served.md 8.2 P4b S6). + +use super::*; + +/// A document as the payment tools and the agings name it ("invoice:7", +/// "bill:12"). +fn doc_ref_text(doc: ledger_core::DocRef) -> String { + match doc { + ledger_core::DocRef::Invoice(id) => format!("invoice:{id}"), + ledger_core::DocRef::Bill(id) => format!("bill:{id}"), + } +} + +/// A document's number, when the books have one: an invoice's issued +/// number, or a bill's vendor invoice number. +fn doc_number(ledger: &Ledger, doc: ledger_core::DocRef) -> Option { + match doc { + ledger_core::DocRef::Invoice(id) => ledger.get_invoice(id).and_then(|i| i.number.clone()), + ledger_core::DocRef::Bill(id) => ledger.get_bill(id).and_then(|b| b.vendor_ref.clone()), + } +} + +/// A document's party (the customer of an invoice, the vendor of a bill). +fn doc_party(ledger: &Ledger, doc: ledger_core::DocRef) -> Result { + match doc { + ledger_core::DocRef::Invoice(id) => ledger + .get_invoice(id) + .map(|i| i.customer_id) + .ok_or_else(|| format!("no invoice {id}: see books.documents")), + ledger_core::DocRef::Bill(id) => ledger + .get_bill(id) + .map(|b| b.vendor_id) + .ok_or_else(|| format!("no bill {id}: see books.documents")), + } +} + +/// The one id a payment tool's single form names: `invoice`/`bill`, or +/// the older `document_id`. Both at once is refused unless they agree. +fn single_document(primary: Option, alias: Option, what: &str) -> Result { + match (primary, alias) { + (Some(a), Some(b)) if a != b => Err(format!( + "pass {what} or document_id, not both: {} and {b} differ", + a + )), + (Some(id), _) | (None, Some(id)) => Ok(id), + (None, None) => Err(format!( + "pass {what} (or an applications list): see books.documents" + )), + } +} + +/// The one account a payment tool's single form names: `deposit_to`/ +/// `paid_from`, or the older `cash_account`. +fn single_account( + primary: &Option, + alias: &Option, + what: &str, +) -> Result { + match (primary, alias) { + (Some(a), Some(b)) if a != b => Err(format!( + "pass {what} or cash_account, not both: {a:?} and {b:?} differ" + )), + (Some(a), _) | (None, Some(a)) => Ok(a.clone()), + (None, None) => Err(format!("pass {what} (or cash_account)")), + } +} + +/// Every document a payment tool's parameters name, as the core takes +/// them: the single form (`invoice`/`bill` with `amount`) or the +/// applications list, never both. +fn applications( + store: &Store, + p: &PaymentParams, + invoices: bool, +) -> Result, String> { + if !p.applications.is_empty() { + if p.amount.is_some() { + return Err( + "pass amount with invoice or bill, or an applications list, not both".into(), + ); + } + return p + .applications + .iter() + .map(|a| { + let (id, other) = if invoices { + (a.invoice, a.bill) + } else { + (a.bill, a.invoice) + }; + if other.is_some() { + return Err(format!( + "an application names {} only, not {}", + if invoices { "invoice" } else { "bill" }, + if invoices { "bill" } else { "invoice" } + )); + } + let id = id.ok_or_else(|| { + format!( + "an application needs its {}", + if invoices { "invoice" } else { "bill" } + ) + })?; + let doc = if invoices { + ledger_core::DocRef::Invoice(id) + } else { + ledger_core::DocRef::Bill(id) + }; + // Refuse an unknown document here, before anything is + // journaled, with the list tool named. + doc_party(store.ledger(), doc)?; + Ok(ledger_core::Application { + doc, + cash: parse_amount(&a.amount)?, + discount: match &a.discount { + Some(d) => parse_amount(d)?, + None => Decimal::ZERO, + }, + discount_reason: a.discount_reason.clone(), + }) + }) + .collect(); + } + let id = single_document( + if invoices { p.invoice } else { p.bill }, + p.document_id, + if invoices { "invoice" } else { "bill" }, + )?; + let amount = p + .amount + .as_deref() + .ok_or_else(|| "pass amount (or an applications list)".to_string())?; + let doc = if invoices { + ledger_core::DocRef::Invoice(id) + } else { + ledger_core::DocRef::Bill(id) + }; + doc_party(store.ledger(), doc)?; + Ok(vec![ledger_core::Application { + doc, + cash: parse_amount(amount)?, + discount: Decimal::ZERO, + discount_reason: None, + }]) +} + +/// The settlement one payment tool describes, and the warnings its +/// parameters add. +fn settlement( + store: &Store, + p: &PaymentParams, + invoices: bool, +) -> Result<(ledger_core::SettleParams, Vec), String> { + if p.applications.is_empty() { + if invoices && p.bill.is_some() { + return Err("books.receive_payment takes invoice, not bill".into()); + } + if !invoices && p.invoice.is_some() { + return Err("books.pay_bill takes bill, not invoice".into()); + } + } + let apps = applications(store, p, invoices)?; + let first = apps + .first() + .ok_or_else(|| "a payment needs at least one invoice or bill".to_string())? + .doc; + let party = doc_party(store.ledger(), first)?; + let cash_account = resolve_account( + store, + &single_account( + if invoices { + &p.deposit_to + } else { + &p.paid_from + }, + &p.cash_account, + if invoices { "deposit_to" } else { "paid_from" }, + )?, + )?; + let details = ledger_core::PaymentDetails { + instrument: p + .instrument + .as_deref() + .map(|r| resolve_instrument(store, r)) + .transpose()?, + reference: p.reference.clone(), + }; + let count = apps.len(); + let base = if invoices { + if count == 1 { + format!( + "Payment on invoice {}", + match first { + ledger_core::DocRef::Invoice(id) => id, + ledger_core::DocRef::Bill(_) => 0, + } + ) + } else { + format!("Payment on {count} invoices") + } + } else if count == 1 { + format!( + "Payment on bill {}", + match first { + ledger_core::DocRef::Bill(id) => id, + ledger_core::DocRef::Invoice(_) => 0, + } + ) + } else { + format!("Payment on {count} bills") + }; + let mut description = details.description(&base); + if !invoices && p.fee.is_some() { + return Err("a processor's fee belongs on a customer payment, not on paying a bill".into()); + } + let fee = match &p.fee { + None => None, + Some(fee) => { + let fee = ledger_core::Fee { + amount: parse_amount(&fee.amount)?, + account: fee + .account + .as_deref() + .map(|a| resolve_account(store, a)) + .transpose()?, + vendor: fee + .vendor + .as_deref() + .map(|v| resolve_vendor(store, v)) + .transpose()?, + }; + if let Some(vendor) = fee.vendor + && let Some(v) = store.ledger().get_vendor(vendor) + { + description = format!("{description} (processing fee to {})", v.name); + } + Some(fee) + } + }; + let mut warnings = Vec::new(); + if p.paid_in_full_marking { + warnings.push( + "The check or note is marked paid in full of a disputed amount. Cashing it may settle \ + the whole claim (UCC §3-311): the books record only this payment; a lawyer or the \ + payer's agreement settles the rest." + .to_string(), + ); + } + Ok(( + ledger_core::SettleParams { + date: parse_date(&p.date)?, + description, + cash_account, + party, + applications: apps, + fee, + instrument: details.instrument, + reference: details.reference, + }, + warnings, + )) +} + +/// What a payment settled: each document's figures after it (derived from +/// its lines that stand, rules.md B-38) and what reached or left the +/// account. +fn payment_out( + ledger: &Ledger, + entry_id: u64, + params: &ledger_core::SettleParams, +) -> Result, String> { + let mut documents = Vec::with_capacity(params.applications.len()); + let mut deposited = Decimal::ZERO; + for app in ¶ms.applications { + deposited += app.cash; + let st = ledger + .doc_state(app.doc, chrono::NaiveDate::MAX) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("{} not found", doc_ref_text(app.doc)))?; + documents.push(PaidDocumentOut { + r#ref: doc_ref_text(app.doc), + number: doc_number(ledger, app.doc), + outstanding: money(st.outstanding), + status: st.status.key().to_string(), + }); + } + let fee = params.fee.as_ref().map_or(Decimal::ZERO, |f| f.amount); + deposited -= fee; + let (remaining, status) = match documents.as_slice() { + [one] => (Some(one.outstanding.clone()), Some(one.status.clone())), + _ => (None, None), + }; + Ok(Json(PaymentOut { + entry_id, + documents, + deposited: money(deposited), + remaining, + status, + warnings: Vec::new(), + })) +} + +#[tool_router(router = settlements_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.receive_payment", + title = "Receive a payment", + description = "Record a customer payment against one or several invoices of one customer. Posts Dr the deposit account (the cash less any processor fee), Dr payment-processing fees the fee, Dr sales allowances the discounts, Cr A/R for each invoice's cash and discount, each receivable line tagged with its invoice. An early-payment discount may be at most the invoice's terms allow and dated by their discount date; a later or larger one needs discount_reason. Name instrument (books.instruments) and the processor's reference. Refused above what an invoice owes, before its date, or into an account that is not cash. The books record payments; they never collect them (D-45).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn receive_payment( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let (settle, warnings) = settlement(&store, &p, true)?; + let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?; + let mut out = payment_out(store.ledger(), entry_id, &settle)?.0; + out.warnings = warnings; + Ok(Json(out)) + } + + #[tool( + name = "books.pay_bill", + title = "Pay a bill", + description = "Record paying one or several vendor bills of one vendor. Posts Dr A/P for each bill's cash and discount, each payable line tagged with its bill; Cr the payer account the cash and purchase discounts the discounts. An early-payment discount may be at most the bill's terms allow and dated by their discount date; a later or larger one needs discount_reason, and a bill with an asset or prepaid line refuses one (record it net, or reclassify Dr 6995 / Cr the asset). Name instrument (books.instruments: a card payment is left out of 1099-NEC) and the reference. Refused above what a bill owes, before its date, or from an account that is not cash. The books record payments; they never make them (D-45).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn pay_bill( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let (settle, _) = settlement(&store, &p, false)?; + let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?; + payment_out(store.ledger(), entry_id, &settle) + } + + #[tool( + name = "books.bills_due", + title = "Bills due", + description = described( + "What to pay and when: every open bill due on or before a day (through; omitted: today), due date first, with its vendor, the vendor's invoice number and what it still owes, and the early-payment discount each vendor's terms give (the last day it is open, the most off, and what to pay to take it). by_date is the running total of what falls due through each date, for planning. The figures are read from the bills' ledger lines that stand (B-38).", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn bills_due( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let through = p + .through + .as_deref() + .map(parse_date) + .transpose()? + .unwrap_or_else(today); + let store = self.lock()?; + let ledger = store.ledger(); + let mut bills: Vec<(DueBillOut, Decimal)> = Vec::new(); + for bill in ledger.bills() { + let doc = ledger_core::DocRef::Bill(bill.id); + let Some(st) = ledger.doc_state(doc, as_of).map_err(|e| e.to_string())? else { + continue; + }; + if st.outstanding <= Decimal::ZERO || bill.due_date > through { + continue; + } + let discounts = bill.terms.as_ref().and_then(|terms| { + let early = terms.discount()?; + let by = terms.discount_by(bill.date)?; + let max = ledger_core::kernel::discount_max(bill.total().ok()?, early.percent_bp)?; + Some((by, max)) + }); + let (discount_by, max_discount, pay) = match discounts { + Some((by, max)) if by >= as_of => ( + Some(by.to_string()), + Some(money(max)), + Some(money(st.outstanding - max)), + ), + _ => (None, None, None), + }; + bills.push(( + DueBillOut { + r#ref: doc_ref_text(doc), + vendor: ledger + .get_vendor(bill.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(), + vendor_ref: bill.vendor_ref.clone(), + due_date: bill.due_date.to_string(), + outstanding: money(st.outstanding), + discount_by, + max_discount, + pay_by_discount_date_amount: pay, + }, + st.outstanding, + )); + } + bills.sort_by_key(|(b, _)| (b.due_date.clone(), b.r#ref.clone())); + let mut by_date = Vec::new(); + let mut running = Decimal::ZERO; + for (bill, owed) in &bills { + running += owed; + match by_date.last_mut() { + Some(DueDateTotalOut { date, amount }) if *date == bill.due_date => { + *amount = money(running); + } + _ => by_date.push(DueDateTotalOut { + date: bill.due_date.clone(), + amount: money(running), + }), + } + } + Ok(Json(BillsDueOut { + as_of: as_of.to_string(), + through: through.to_string(), + bills: bills.into_iter().map(|(b, _)| b).collect(), + by_date, + })) + } + + #[tool( + name = "books.ar_aging", + title = "Receivables aging", + description = described( + "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+), with the receivables account's tie-out (its balance against what its invoices owe, its lines no invoice's, the difference: always 0.00). detail: true adds one row per invoice, with its number, dates and terms.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn ar_aging(&self, params: Parameters) -> Result, String> { + aging(self, params.0, true) + } + + #[tool( + name = "books.ap_aging", + title = "Payables aging", + description = described( + "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+), with the payables account's tie-out (its balance against what its bills owe, its lines no bill's, the difference: always 0.00). detail: true adds one row per bill, with its number, dates and terms.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn ap_aging(&self, params: Parameters) -> Result, String> { + aging(self, params.0, false) + } +} + +/// An aging of invoices (`invoices` true) or bills, with its tie-out. +fn aging(server: &BooksServer, p: AgingParams, invoices: bool) -> Result, String> { + let as_of = server.as_of(p.as_of.as_deref())?; + let store = server.lock()?; + let ledger = store.ledger(); + let rows = if invoices { + ledger.ar_aging(as_of) + } else { + ledger.ap_aging(as_of) + } + .map_err(|e| e.to_string())? + .into_iter() + .map(|r| { + let total = r.total().map_err(|e| e.to_string())?.to_string(); + Ok(AgingRowOut { + name: r.name, + current: money(r.current), + days_30: money(r.days_30), + days_60: money(r.days_60), + days_90: money(r.days_90), + days_90_plus: money(r.days_90_plus), + total, + }) + }) + .collect::, String>>()?; + let documents = if p.detail { + ledger + .aging_documents(as_of, invoices) + .map_err(|e| e.to_string())? + .into_iter() + .map(|d| { + Ok(AgingDocumentOut { + r#ref: doc_ref_text(d.doc), + number: doc_number(ledger, d.doc), + party: d.party, + date: d.date.to_string(), + due_date: d.due_date.to_string(), + terms: d.terms, + total: money(d.total), + paid: money(d.paid), + discounted: money(d.discounted), + outstanding: money(d.outstanding), + bucket: d.bucket.to_string(), + days_overdue: d.days_overdue, + }) + }) + .collect::, String>>()? + } else { + Vec::new() + }; + let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; + let role = if invoices { "receivables" } else { "payables" }; + let control = match checks.iter().find(|c| c.role == role) { + Some(check) => { + let account = ledger.chart().get(check.account); + AgingControlOut { + account: match account { + Some(a) => format!("{} {}", a.code, a.name), + None => String::new(), + }, + balance: money(check.balance), + documents_total: money(check.documents_total), + unattached: check + .unattached + .iter() + .map(|u| UnattachedLineOut { + entry_id: u.entry, + line: u.line, + date: u.date.to_string(), + amount: money(u.amount), + }) + .collect(), + difference: money(check.difference), + } + } + // No document of this kind has posted yet: nothing to tie out. + None => AgingControlOut { + account: String::new(), + balance: money(Decimal::ZERO), + documents_total: money(Decimal::ZERO), + unattached: Vec::new(), + difference: money(Decimal::ZERO), + }, + }; + Ok(Json(AgingOut { + as_of: as_of.to_string(), + rows, + documents, + control, + })) +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 8057e36..b84c59d 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3170,17 +3170,73 @@ pub struct CreateBillParams { pub confirmed: Option, } +/// One invoice or bill a payment applies to (plan P4b S6; B-42). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PaymentApplicationParam { + /// The invoice (books.receive_payment) or bill (books.pay_bill), by id. + #[serde(default)] + pub invoice: Option, + /// The bill (books.pay_bill), by id. + #[serde(default)] + pub bill: Option, + /// The cash paid against it, decimal string, positive. + pub amount: String, + /// An early-payment discount taken off it, decimal string. + #[serde(default)] + pub discount: Option, + /// Why a discount outside the document's terms was taken (a late + /// discount the vendor honours): one line, recorded with the payment. + #[serde(default)] + pub discount_reason: Option, +} + +/// A processor's fee withheld from a customer payment (plan P4b S6; A-4). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct FeeParam { + /// The fee, decimal string, positive. + pub amount: String, + /// The expense account it goes to (id or code); omitted: the + /// payment-processing-fees role's account. + #[serde(default)] + pub account: Option, + /// The processor, when the books know it as a vendor (id or name). + #[serde(default)] + pub vendor: Option, +} + /// Parameters for `books.receive_payment` / `books.pay_bill`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PaymentParams { - /// Invoice id (receive_payment) or bill id (pay_bill). - pub document_id: u64, + /// Invoice id (receive_payment) or bill id (pay_bill); the older name + /// of `invoice` / `bill`. + #[serde(default)] + pub document_id: Option, + /// The invoice, by id (books.receive_payment). + #[serde(default)] + pub invoice: Option, + /// The bill, by id (books.pay_bill). + #[serde(default)] + pub bill: Option, /// Payment date, YYYY-MM-DD. pub date: String, - /// Payment amount, decimal string. - pub amount: String, - /// Cash account id or code. - pub cash_account: String, + /// Payment amount, decimal string; with `invoice` or `bill` only. The + /// applications' amounts are the cash; a discount is beside it. + #[serde(default)] + pub amount: Option, + /// Cash account id or code; the older name of `deposit_to` / + /// `paid_from`. + #[serde(default)] + pub cash_account: Option, + /// The account the cash reaches (receive_payment): a bank or card that + /// holds no role. + #[serde(default)] + pub deposit_to: Option, + /// The account the cash leaves (pay_bill): a bank, card or payable + /// that holds no role. + #[serde(default)] + pub paid_from: Option, /// How the money moved: a payment method's id or label /// (`books.instruments`), tagged on the payment's entry in the same /// call (D-40: a card payment is left out of 1099-NEC). @@ -3191,6 +3247,34 @@ pub struct PaymentParams { /// carried in the entry's description (D-45). #[serde(default)] pub reference: Option, + /// Several invoices (receive_payment) or bills (pay_bill) settled by + /// one payment, one party. + #[serde(default)] + pub applications: Vec, + /// The processor's fee withheld from a customer payment (never on a + /// bill): receivables are credited the gross, the fee goes to expense, + /// the deposit account gets the net. + #[serde(default)] + pub fee: Option, + /// A check or note marked "paid in full" for a disputed amount: adds a + /// warning under UCC §3-311 and records nothing else. + #[serde(default)] + pub paid_in_full_marking: bool, +} + +/// One document a payment settled, after it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PaidDocumentOut { + /// "invoice:7" or "bill:12". + pub r#ref: String, + /// Its number, when it has one ("INV-0007"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub number: Option, + /// What it still owes after the payment, decimal string. + pub outstanding: String, + /// Its status after it: open, partial, paid, written_off, void, + /// overpaid, or unattached. + pub status: String, } /// Output of `books.receive_payment` / `books.pay_bill`. @@ -3198,12 +3282,22 @@ pub struct PaymentParams { pub struct PaymentOut { /// The payment journal entry's id. pub entry_id: u64, - /// What the document still owes after it, decimal string (derived - /// from its lines that stand). - pub remaining: String, - /// The document's status after it: open, partial, paid, written_off, - /// void, overpaid, or unattached. - pub status: String, + /// Every document it settled, in application order. + pub documents: Vec, + /// What reached (receive_payment) or left (pay_bill) the account: the + /// cash less a processor's fee, decimal string. + pub deposited: String, + /// What the single document still owes (the older shape; present when + /// one document was settled). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remaining: Option, + /// The single document's status (the older shape). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// What to tell the human (a "paid in full" marking's UCC §3-311 + /// warning). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } /// Parameters for `books.documents`. @@ -3449,6 +3543,10 @@ pub struct AgingParams { #[serde(default)] #[schemars(description = AS_OF_DOC)] pub as_of: Option, + /// Also one row per document, with its number, dates, terms and + /// bucket. + #[serde(default)] + pub detail: bool, } /// One row of an aging report. @@ -3470,6 +3568,53 @@ pub struct AgingRowOut { pub total: String, } +/// One document in an aging's detail. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AgingDocumentOut { + /// "invoice:7" or "bill:12". + pub r#ref: String, + /// Its number, when it has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub number: Option, + /// The customer or vendor. + pub party: String, + /// Its own date. + pub date: String, + /// When payment is due. + pub due_date: String, + /// Its terms, as they print ("2/10 net 30"), when it has them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terms: Option, + /// The sum of its lines. + pub total: String, + /// Cash paid that stands. + pub paid: String, + /// Discounts taken that stand. + pub discounted: String, + /// What it still owes as of the day. + pub outstanding: String, + /// current, days_30, days_60, days_90 or days_90_plus. + pub bucket: String, + /// Days past its due date while something is owed; 0 otherwise. + pub days_overdue: i64, +} + +/// The aging's account against its documents (the control check, B-23, +/// B-38 INVARIANT 1). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AgingControlOut { + /// "1200 Accounts Receivable" or "2000 Accounts Payable". + pub account: String, + /// Its ledger balance as of the day. + pub balance: String, + /// What its documents owe on it, together. + pub documents_total: String, + /// The lines on it that are no document's. + pub unattached: Vec, + /// balance − documents_total − unattached: always 0.00. + pub difference: String, +} + /// Output of `books.ar_aging` / `books.ap_aging`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AgingOut { @@ -3477,6 +3622,71 @@ pub struct AgingOut { pub as_of: String, /// Rows, one per party with an outstanding balance. pub rows: Vec, + /// One row per open document, when `detail` was asked for. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub documents: Vec, + /// The account's tie-out. + pub control: AgingControlOut, +} + +/// Parameters for `books.bills_due`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct BillsDueParams { + /// Show bills due on or before this day, YYYY-MM-DD (omitted: today). + #[serde(default)] + pub through: Option, + /// As of this day for what each bill owes ([`AS_OF_DOC`]). + #[serde(default)] + #[schemars(description = AS_OF_DOC)] + pub as_of: Option, +} + +/// One bill to pay, with its discount window when its terms give one. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DueBillOut { + /// "bill:12". + pub r#ref: String, + /// The vendor. + pub vendor: String, + /// The vendor's own invoice number, when recorded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vendor_ref: Option, + /// When payment is due. + pub due_date: String, + /// What it still owes as of the day. + pub outstanding: String, + /// The last day its early-payment discount is open, when it has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discount_by: Option, + /// The most its terms allow off, decimal string. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_discount: Option, + /// What to pay to take the discount: the outstanding less the + /// discount, decimal string. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pay_by_discount_date_amount: Option, +} + +/// One date's running total of what falls due through it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DueDateTotalOut { + /// The due date. + pub date: String, + /// What falls due on or before it, together. + pub amount: String, +} + +/// Output of `books.bills_due`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct BillsDueOut { + /// The day it is as of. + pub as_of: String, + /// The last day a bill is included for. + pub through: String, + /// Bills due by `through`, due date first. + pub bills: Vec, + /// The running total, one row per due date. + pub by_date: Vec, } // --------------------------------------------------------------------------- diff --git a/crates/ledger-package/src/aging.rs b/crates/ledger-package/src/aging.rs new file mode 100644 index 0000000..2d842db --- /dev/null +++ b/crates/ledger-package/src/aging.rs @@ -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 ,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 93b4937..5060207 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/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index c3f6945..5e6590b 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2914,26 +2914,6 @@ impl Store { }) } - /// Journal an auto-posted two-line entry (debit one account, credit - /// another for the same amount): [`Self::journal_auto_lines`]. - fn journal_auto_entry( - &mut self, - date: NaiveDate, - description: &str, - debit_account: AccountId, - credit_account: AccountId, - amount: Decimal, - ) -> Result<(EntryId, Vec), StoreError> { - self.journal_auto_lines( - date, - description, - &[ - (debit_account, Side::Debit, amount, None), - (credit_account, Side::Credit, amount, None), - ], - ) - } - /// Journal an auto-posted entry of `lines` (account, side, amount, /// project) through the standard entry.created / entry.line_added / /// entry.posted flow, a tagged line's `entry.line_class_set` right @@ -3606,42 +3586,26 @@ impl Store { cash_account: AccountId, details: &ledger_core::PaymentDetails, ) -> Result { - self.rehearse(|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details))?; - self.approval_gate( - |l| l.receive_payment_with(invoice_id, date, amount, cash_account, details), - false, - )?; - // The rehearsal above is the whole check: hand-written mirrors of - // the engine's refusals drift from it (and were never reached). - self.ensure_payment_features(ledger_core::DocRef::Invoice(invoice_id), amount, details)?; - let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; - let description = details.description(&format!("Payment on invoice {invoice_id}")); - let (entry_id, mut journaled) = - self.journal_auto_entry(date, &description, cash_account, ar, amount)?; - journaled.push(self.append_journal( - "invoice.payment_received", - json!({ - "invoice_id": invoice_id, - "date": date.to_string(), - "amount": amount.to_string(), - "cash_account": cash_account, - "entry_id": entry_id, - }), - )?); - if details.instrument.is_some() { - journaled.push(self.append_journal( - "entry.instrument_set", - crate::form1099::entry_instrument_set(entry_id, details.instrument), - )?); - } - let live = - self.ledger - .receive_payment_with(invoice_id, date, amount, cash_account, details)?; - debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); - for line in &journaled { - apply_line_to_sqlite(&self.conn, line)?; - } - Ok(live) + let customer = self + .ledger + .get_invoice(invoice_id) + .ok_or(ledger_core::LedgerError::UnknownInvoice(invoice_id))? + .customer_id; + self.settle(ledger_core::SettleParams { + date, + description: details.description(&format!("Payment on invoice {invoice_id}")), + cash_account, + party: customer, + applications: vec![ledger_core::Application { + doc: ledger_core::DocRef::Invoice(invoice_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), + }) } /// Pay a vendor bill ([`Self::pay_bill_with`], with no payment method @@ -3675,35 +3639,140 @@ impl Store { cash_account: AccountId, details: &ledger_core::PaymentDetails, ) -> Result { - self.rehearse(|l| l.pay_bill_with(bill_id, date, amount, cash_account, details))?; - self.approval_gate( - |l| l.pay_bill_with(bill_id, date, amount, cash_account, details), - false, - )?; - self.ensure_payment_features(ledger_core::DocRef::Bill(bill_id), amount, details)?; - let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; - let description = details.description(&format!("Payment on bill {bill_id}")); + let vendor = self + .ledger + .get_bill(bill_id) + .ok_or(ledger_core::LedgerError::UnknownBill(bill_id))? + .vendor_id; + self.settle(ledger_core::SettleParams { + date, + description: details.description(&format!("Payment on bill {bill_id}")), + cash_account, + party: vendor, + applications: vec![ledger_core::Application { + doc: ledger_core::DocRef::Bill(bill_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), + }) + } + + /// Settle one or more invoices or bills in one entry (plan P4b S6; + /// rules.md B-42): the core posts it ([`ledger_core::Ledger::settle`]); + /// this journals its lines once and one `invoice.payment_received` or + /// `bill.paid` per application, each naming the control line it tags, + /// then the payment method. A single legacy-shaped payment is + /// journaled exactly as before; more than one application, a discount + /// or a fee needs `documents_v2`, and a payment accepted only because + /// an earlier one was reversed needs `document_standing`. + pub fn settle(&mut self, params: ledger_core::SettleParams) -> Result { + self.rehearse(|l| l.settle(params.clone()))?; + self.approval_gate(|l| l.settle(params.clone()), false)?; + self.ensure_payment_features(¶ms)?; + let kind = match params.applications.first().map(|a| a.doc) { + Some(ledger_core::DocRef::Bill(_)) => ledger_core::DocumentKind::Bill, + _ => ledger_core::DocumentKind::Invoice, + }; + let control_role = match kind { + ledger_core::DocumentKind::Invoice => ledger_core::RECEIVABLES, + ledger_core::DocumentKind::Bill => ledger_core::PAYABLES, + }; + self.ensure_control_account(&control_role)?; + if params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + { + let role = match kind { + ledger_core::DocumentKind::Invoice => ledger_core::SALES_ALLOWANCES, + ledger_core::DocumentKind::Bill => ledger_core::PURCHASE_DISCOUNTS, + }; + self.ensure_control_account(&role)?; + } + if params.fee.as_ref().is_some_and(|f| f.account.is_none()) { + self.ensure_control_account(&ledger_core::PAYMENT_PROCESSING_FEES)?; + } + let rows = self.ledger.settlement_lines(¶ms)?; + let simple: Vec<(AccountId, Side, Decimal, Option)> = rows + .iter() + .map(|&(account, side, amount, _)| (account, side, amount, None)) + .collect(); + let description = if params.description.trim().is_empty() { + format!( + "Settlement of {} document{}", + params.applications.len(), + if params.applications.len() == 1 { + "" + } else { + "s" + } + ) + } else { + params.description.clone() + }; let (entry_id, mut journaled) = - self.journal_auto_entry(date, &description, ap, cash_account, amount)?; - journaled.push(self.append_journal( - "bill.paid", - json!({ - "bill_id": bill_id, - "date": date.to_string(), - "amount": amount.to_string(), - "cash_account": cash_account, + self.journal_auto_lines(params.date, &description, &simple)?; + let documents_v2 = params.applications.len() > 1 + || params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + || params.fee.is_some(); + let discounts = params + .applications + .iter() + .filter(|a| a.discount > Decimal::ZERO) + .count(); + let fee_line = params.applications.len() + discounts; + let (event, id_key) = match kind { + ledger_core::DocumentKind::Invoice => ("invoice.payment_received", "invoice_id"), + ledger_core::DocumentKind::Bill => ("bill.paid", "bill_id"), + }; + for (i, app) in params.applications.iter().enumerate() { + let doc_id = match app.doc { + ledger_core::DocRef::Invoice(id) | ledger_core::DocRef::Bill(id) => id, + }; + let mut payload = json!({ + "date": params.date.to_string(), + "amount": app.cash.to_string(), + "cash_account": params.cash_account, "entry_id": entry_id, - }), - )?); - if details.instrument.is_some() { + }); + payload[id_key] = json!(doc_id); + if documents_v2 { + payload["line"] = json!(i); + if app.discount > Decimal::ZERO { + let line = params.applications.len() + + params.applications[..i] + .iter() + .filter(|a| a.discount > Decimal::ZERO) + .count(); + payload["discount"] = json!(app.discount.to_string()); + payload["discount_line"] = json!(line); + if let Some(reason) = &app.discount_reason { + payload["discount_reason"] = json!(reason); + } + } + if let Some(fee) = ¶ms.fee + && i == 0 + { + payload["fee"] = json!(fee.amount.to_string()); + payload["fee_line"] = json!(fee_line); + } + } + journaled.push(self.append_journal(event, payload)?); + } + if params.instrument.is_some() { journaled.push(self.append_journal( "entry.instrument_set", - crate::form1099::entry_instrument_set(entry_id, details.instrument), + crate::form1099::entry_instrument_set(entry_id, params.instrument), )?); } - let live = self - .ledger - .pay_bill_with(bill_id, date, amount, cash_account, details)?; + let live = self.ledger.settle(params)?; debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); for line in &journaled { apply_line_to_sqlite(&self.conn, line)?; @@ -3711,29 +3780,39 @@ impl Store { Ok(live) } - /// The journal features a payment of `amount` on `doc` needs - /// ([`Self::receive_payment_with`] SCENARIOS 2 and 3): document_standing - /// when more than an older version thinks is still owed (the - /// document's total less every payment event it recorded, reversed - /// or not, its line tagged or not: `Ledger::payments_recorded`), - /// payment_instruments when it names a payment method. + /// The journal features a settlement needs (rules.md B-27, B-42): + /// `documents_v2` for more than one application, a discount or a fee + /// (an older version would drop them, and the balance would be wrong), + /// `document_standing` when an application pays more than an older + /// version thinks is still owed (the document's total less every + /// payment event it recorded, reversed or not, its line tagged or + /// not), and `payment_instruments` when it names a payment method. fn ensure_payment_features( &self, - doc: ledger_core::DocRef, - amount: Decimal, - details: &ledger_core::PaymentDetails, + params: &ledger_core::SettleParams, ) -> Result<(), StoreError> { - let total = match doc { - ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()), - ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()), - }; - if let Some(total) = total { - let older_owed = total?.checked_sub(self.ledger.payments_recorded(doc)?); - if older_owed.is_none_or(|owed| amount > owed) { - self.ensure_feature(crate::format::DOCUMENT_STANDING)?; + if params.applications.len() > 1 + || params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + || params.fee.is_some() + { + self.ensure_feature(crate::format::DOCUMENTS_V2)?; + } + for app in ¶ms.applications { + let total = match app.doc { + ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()), + ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()), + }; + if let Some(total) = total { + let older_owed = total?.checked_sub(self.ledger.payments_recorded(app.doc)?); + if older_owed.is_none_or(|owed| app.cash > owed) { + self.ensure_feature(crate::format::DOCUMENT_STANDING)?; + } } } - if details.instrument.is_some() { + if params.instrument.is_some() { self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?; } Ok(()) @@ -6489,6 +6568,31 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> { .ok_or_else(|| StoreError::JournalCorrupt(format!("missing/invalid '{key}'"))) } +/// An optional integer a newer payment event carries (its `line` or +/// `discount_line`): absent or null is none; anything else must be an +/// integer. +fn get_opt_usize(p: &Value, key: &str) -> Result, StoreError> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .map(|n| Some(n as usize)) + .ok_or_else(|| StoreError::JournalCorrupt(format!("invalid '{key}'"))), + } +} + +/// An optional decimal string a newer payment event carries (its +/// `discount`): absent or null is zero. +fn get_opt_decimal(p: &Value, key: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(Decimal::ZERO), + Some(v) => v + .as_str() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| StoreError::JournalCorrupt(format!("invalid '{key}'"))), + } +} + /// Replay one journal line into a ledger, asserting recorded ids. Used by /// hydration, `verify`, and `rebuild`: the journal is the script, the engine /// is the actor. @@ -7073,23 +7177,30 @@ fn replay_line( ledger.replay_invoice_sent(invoice_id, sent)?; } "invoice.payment_received" => { - // The payment entry was already rebuilt; this only moves the - // invoice's paid amount and status. - ledger.replay_invoice_payment( + // The payment entry was already rebuilt; this only tags its + // receivables lines (the line the event names, or the first + // untagged one for a legacy event) and moves the invoice. + ledger.replay_invoice_payment_at( get_u64(p, "invoice_id")?, get_str(p, "amount")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?, get_u64(p, "entry_id")?, + get_opt_usize(p, "line")?, + get_opt_decimal(p, "discount")?, + get_opt_usize(p, "discount_line")?, )?; } "bill.paid" => { - ledger.replay_bill_payment( + ledger.replay_bill_payment_at( get_u64(p, "bill_id")?, get_str(p, "amount")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?, get_u64(p, "entry_id")?, + get_opt_usize(p, "line")?, + get_opt_decimal(p, "discount")?, + get_opt_usize(p, "discount_line")?, )?; } "fx.rate_set" => { diff --git a/crates/ledger-store/tests/arap_props.proptest-regressions b/crates/ledger-store/tests/arap_props.proptest-regressions index 788a3a7..cdae27a 100644 --- a/crates/ledger-store/tests/arap_props.proptest-regressions +++ b/crates/ledger-store/tests/arap_props.proptest-regressions @@ -5,3 +5,4 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 704ab49c9bea84359475c3afa56c3354275a353670fc71156fba151f846f4f73 # shrinks to first = [Create(false, 0, 1), Create(false, 0, 1)], then = [Close(1), Hand(false, 1)] +cc f678814ec34db3abef40116e17e562fe5abe652c09f12600e19841beaf9d6fe6 # shrinks to first = [Create(false, 0, 13902), Settle { a: 0, b: 0, n: 0, cash: 6676, discount: 551, fee: 0 }], then = [] diff --git a/crates/ledger-store/tests/arap_props.rs b/crates/ledger-store/tests/arap_props.rs index 89cda5d..40ba0eb 100644 --- a/crates/ledger-store/tests/arap_props.rs +++ b/crates/ledger-store/tests/arap_props.rs @@ -71,6 +71,16 @@ enum Op { Close(u8), /// A hand line on receivables (true) or payables: always refused. Hand(bool, i64), + /// One payment settling two documents of one party, the first with a + /// discount and (on invoices) a processor's fee. + Settle { + a: usize, + b: usize, + n: u8, + cash: i64, + discount: i64, + fee: i64, + }, } fn arb_op() -> impl Strategy { @@ -83,6 +93,15 @@ fn arb_op() -> impl Strategy { 2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)), 1 => (1u8..4).prop_map(Op::Close), 1 => (any::(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)), + 2 => (0usize..8, 0usize..8, 0u8..90, 1i64..20_000, 0i64..2_000, 0i64..500) + .prop_map(|(a, b, n, cash, discount, fee)| Op::Settle { + a, + b, + n, + cash, + discount, + fee, + }), ] } @@ -220,6 +239,67 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec) -> Result<( }) .map(drop) } + Op::Settle { .. } if docs.is_empty() => Ok(()), + Op::Settle { + a, + b, + n, + cash, + discount, + fee, + } => { + let first = docs[a % docs.len()]; + let mut second = docs[b % docs.len()]; + if second == first && docs.len() > 1 { + second = docs[(b + 1) % docs.len()]; + } + let same_kind = matches!( + (first, second), + (DocRef::Invoice(_), DocRef::Invoice(_)) | (DocRef::Bill(_), DocRef::Bill(_)) + ); + if !same_kind { + // One party and one kind per settlement: not this pair. + Ok(()) + } else { + let party = match first { + DocRef::Invoice(id) => s.ledger().get_invoice(id).map(|i| i.customer_id), + DocRef::Bill(id) => s.ledger().get_bill(id).map(|v| v.vendor_id), + } + .unwrap(); + let invoices = matches!(first, DocRef::Invoice(_)); + let money = cents(cash); + let apps = vec![ + ledger_core::Application { + doc: first, + cash: money, + discount: cents(discount), + discount_reason: (discount > 0) + .then(|| format!("a late discount, n {n}")), + }, + ledger_core::Application { + doc: second, + cash: money, + discount: Decimal::ZERO, + discount_reason: None, + }, + ]; + s.settle(ledger_core::SettleParams { + date: day(n), + description: "two documents, one payment".into(), + cash_account: k.bank, + party, + applications: apps, + fee: (invoices && fee > 0).then_some(ledger_core::Fee { + amount: cents(fee), + account: None, + vendor: None, + }), + instrument: None, + reference: None, + }) + .map(drop) + } + } Op::Hand(receivables, c) => { let code = if receivables { "1200" } else { "2000" }; match s.ledger().chart().find_by_code(code).map(|a| a.id) { diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index 18b4cb5..6a5dab5 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -1241,3 +1241,217 @@ fn mark_sent_on_void_refused() { assert_eq!(s.ledger().get_invoice(inv).unwrap().sends.len(), 1); s.verify().unwrap(); } + +/// A settlement of two invoices, a discount and a processor's fee: one +/// entry whose events name the lines they tag, every figure derived, and +/// the books replaying to the same lines (plan P4b S6; rules.md B-42 +/// INVARIANTS 1 and 3). +#[test] +fn a_settlement_names_its_lines_and_replays() { + use ledger_core::{Application, DocRef, Fee, SettleParams}; + let dir = test_dir("settlement-lines"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let c = s.add_customer("Fernhill Labs").unwrap(); + let first = s + .create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 7_000)]) + .unwrap(); + let second = s + .create_invoice( + c, + d(2026, 4, 2), + d(2026, 5, 2), + vec![line("Support", 2_000)], + ) + .unwrap(); + let from = journal_len(&dir.0); + let entry = s + .settle(SettleParams { + date: d(2026, 4, 10), + description: "One deposit, two invoices".into(), + cash_account: bank, + party: c, + applications: vec![ + Application { + doc: DocRef::Invoice(first), + cash: cents(6_000), + discount: cents(1_000), + discount_reason: Some("vendor honoured the early discount late".into()), + }, + Application { + doc: DocRef::Invoice(second), + cash: cents(2_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: Some(Fee { + amount: cents(500), + account: None, + vendor: None, + }), + instrument: None, + reference: Some("ch_42".into()), + }) + .unwrap(); + let got = since(&dir.0, from); + // The two role accounts created on demand, then the entry, then one + // event per application. + assert_eq!( + got.iter().filter(|(t, _)| t == "account.added").count(), + 2, + "{got:?}" + ); + assert_eq!( + got.iter() + .filter(|(t, _)| t == "invoice.payment_received") + .count(), + 2 + ); + assert_eq!( + got.iter().filter(|(t, _)| t == "entry.line_added").count(), + 6 + ); + // One credit per invoice's cash, one for the discount, the fee, the + // discount expense and the net deposit: the rows the event lines name. + let events: Vec<&Value> = got + .iter() + .filter(|(t, _)| t == "invoice.payment_received") + .map(|(_, p)| p) + .collect(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["line"], json!(0)); + assert_eq!(events[0]["discount"], json!("10.00")); + assert_eq!(events[0]["discount_line"], json!(2)); + assert_eq!(events[0]["fee"], json!("5.00")); + assert_eq!(events[0]["fee_line"], json!(3)); + assert_eq!(events[1]["line"], json!(1)); + assert!(events[1].get("discount").is_none()); + assert!(events[1].get("fee").is_none()); + let live = s.ledger().clone(); + let ar = live.control_check(d(2026, 4, 10)).unwrap(); + let receivables = ar.iter().find(|c| c.role == "receivables").unwrap(); + assert_eq!(receivables.balance, cents(0)); + assert_eq!(receivables.documents_total, cents(0)); + assert_eq!(receivables.difference, Decimal::ZERO); + for (id, paid, discounted) in [(first, 6_000, 1_000), (second, 2_000, 0)] { + let st = live.invoice_state(id, d(2026, 4, 10)).unwrap().unwrap(); + assert_eq!( + (st.paid, st.discounted, st.outstanding), + (cents(paid), cents(discounted), Decimal::ZERO) + ); + } + assert_eq!(s.ledger().get_entry(entry).unwrap().lines().len(), 6); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!( + live.differs_from(s.ledger()).is_empty(), + "{:?}", + live.differs_from(s.ledger()) + ); + s.verify().unwrap(); +} + +/// A settlement of more than one document, a discount or a fee needs +/// `documents_v2`: refused, journaling nothing, in books without it, and +/// accepted after the owner's upgrade, replaying to the same books (plan +/// P4b S6; rules.md B-27, B-42). +#[test] +fn settlement_waits_for_documents_v2() { + use ledger_core::{Application, DocRef, SettleParams}; + let dir = fixture("settlement-v2"); + let mut s = Store::open(&dir.0).unwrap(); + assert!( + !s.journal_format() + .has_feature(ledger_store::format::DOCUMENTS_V2) + ); + let bank = s.ledger().chart().find_by_code("1000").unwrap().id; + let head = s.head(); + let params = |discount: Decimal| SettleParams { + date: d(2026, 2, 15), + description: "Two bills, one payment".into(), + cash_account: bank, + party: 1, + applications: vec![ + Application { + doc: DocRef::Bill(1), + cash: cents(7_000), + discount, + discount_reason: (!discount.is_zero()).then_some("late discount".into()), + }, + Application { + doc: DocRef::Bill(2), + cash: cents(4_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: None, + instrument: None, + reference: None, + }; + match s.settle(params(Decimal::ZERO)) { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature documents_v2") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + assert_eq!(s.head(), head, "nothing journaled"); + s.upgrade_format().unwrap(); + // The discount is outside the invoice's terms (it has none), so it + // needs its reason; the payment is also accepted only because the + // earlier one was reversed, which needs document_standing too. + s.settle(params(cents(1_000))).unwrap(); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!( + live.differs_from(s.ledger()).is_empty(), + "{:?}", + live.differs_from(s.ledger()) + ); + let st = s.ledger().bill_state(1, d(2026, 12, 31)).unwrap().unwrap(); + assert_eq!(st.status, ledger_core::DocStatus::Paid); +} + +/// Legacy payment events carry no `line`, so replay keeps the positional +/// rule: the first untagged control line for the payment's amount, in the +/// order the events come (plan P4b S6; rules.md B-42 INVARIANT 3). +#[test] +fn legacy_payment_events_keep_the_positional_rule() { + use ledger_core::{DocRef, Movement}; + let dir = fixture("legacy-positional"); + let s = Store::open(&dir.0).unwrap(); + let payments: Vec = std::fs::read_to_string(dir.0.join(JOURNAL_FILE)) + .unwrap() + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .filter(|v| v["type"] == json!("invoice.payment_received")) + .map(|v| v["payload"].clone()) + .collect(); + assert!(!payments.is_empty()); + for p in &payments { + assert!(p.get("line").is_none(), "{p}"); + } + let lines = s.ledger().doc_lines(DocRef::Invoice(3)); + assert_eq!( + lines + .iter() + .map(|l| (l.movement, l.amount)) + .collect::>(), + [ + (Movement::Origin, cents(30_000)), + (Movement::Payment, cents(-12_000)) + ] + ); + assert_eq!( + s.ledger() + .line_doc(lines[1].entry, lines[1].line) + .unwrap() + .doc, + DocRef::Invoice(3) + ); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index f212c66..ca30f3b 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1875,6 +1875,8 @@ fn books_written_under_older_rules_still_open() { (4, "4000", "Revenue", "Revenue"), (5, "2000", "Accounts Payable", "Liability"), (6, "6500", "Supplies", "Expense"), + (7, "4100", "Refunds and Allowances", "Revenue"), + (8, "1020", "Bank", "Asset"), ] { w.append( "account.added", @@ -1966,13 +1968,55 @@ fn books_written_under_older_rules_still_open() { "to": ["billing@fernhill.example"], "kind": "original", "reference": "msg-1"}), ) .unwrap(); + // P4b S6's new shape, hand-written as this version writes it: a + // payment event naming its line and the discount line it also tags. + // This reader tags both; the version before it would drop the discount + // and read the invoice paid above its total. + w.append( + "entry.created", + json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1"}), + ) + .unwrap(); + for (account, side, amount) in [ + (3, "credit", "98.99"), + (3, "credit", "1.00"), + (7, "debit", "1.00"), + (8, "debit", "98.99"), + ] { + w.append( + "entry.line_added", + json!({"id": 4, "account": account, "side": side, "amount": amount, + "currency": "USD"}), + ) + .unwrap(); + } + w.append( + "entry.posted", + json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1", + "reversal_of": null, + "lines": [ + {"account": 3, "side": "credit", "amount": "98.99", "currency": "USD"}, + {"account": 3, "side": "credit", "amount": "1.00", "currency": "USD"}, + {"account": 7, "side": "debit", "amount": "1.00", "currency": "USD"}, + {"account": 8, "side": "debit", "amount": "98.99", "currency": "USD"} + ]}), + ) + .unwrap(); + w.append( + "invoice.payment_received", + json!({"invoice_id": 1, "date": "2026-04-05", "amount": "98.99", + "cash_account": 8, "entry_id": 4, "line": 0, + "discount": "1.00", "discount_line": 1, + "discount_reason": "the customer paid the rest late"}), + ) + .unwrap(); drop(w); // Opening rebuilds from the journal; it must accept all of it. let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); let store = Store::open(&dir).expect("older books still open"); store.verify().unwrap(); assert_eq!(store.journal_format().format, 1); - assert_eq!(store.ledger().chart().len(), 6); + assert_eq!(store.ledger().chart().len(), 8); assert_eq!(store.ledger().list_periods().len(), 2); let lines = store.ledger().get_entry(1).unwrap().lines(); assert_eq!(lines.len(), 2); @@ -2040,6 +2084,30 @@ fn books_written_under_older_rules_still_open() { assert_eq!(sends[0].to, ["billing@fernhill.example"]); assert_eq!(sends[0].kind, ledger_core::arap::SendKind::Original); assert_eq!(sends[0].reference.as_deref(), Some("msg-1")); + // The S6 shape: the invoice reads paid and discounted, and replay + // tagged the lines the event names. + let st = store + .ledger() + .invoice_state(1, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()) + .unwrap() + .unwrap(); + assert_eq!( + (st.status, st.paid, st.discounted, st.outstanding), + ( + ledger_core::DocStatus::Paid, + "98.99".parse::().unwrap(), + "1.00".parse::().unwrap(), + Decimal::ZERO + ) + ); + assert_eq!( + store.ledger().line_doc(4, 0).unwrap().movement, + ledger_core::Movement::Payment + ); + assert_eq!( + store.ledger().line_doc(4, 1).unwrap().movement, + ledger_core::Movement::Discount + ); } /// The books an earlier wanderbooks wrote, one directory each under diff --git a/docs/architecture.md b/docs/architecture.md index 17bd94c..7b54dea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -167,9 +167,12 @@ each declared with `#[path]`, opening with `use super::*`, and holding a BooksServer`; `server.rs`'s `tool_router` adds every group's router. The groups are: -- `documents` — invoices, bills, payments, voids, `books.documents`, - `books.document`, the control check and the agings; +- `documents` — invoices, bills, voids, `books.documents`, + `books.document` and the control check; - `sent` — `books.mark_sent`, the record that an invoice went out (P4b S5); +- `settlements` — payments that settle one or several documents + (`books.receive_payment`, `books.pay_bill`), `books.bills_due`, and the + agings (`books.ar_aging`, `books.ap_aging`) with their tie-out (P4b S6); - `entries` — `books.draft_entry`, `books.add_line`, `books.post_entry`, `books.reverse_entry`, `books.get_entry`, `books.search_entries`, the bank CSV import; diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 0d39e21..f33c165 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -1688,7 +1688,11 @@ posting. `journal`: each entry posted in the period, line by line; `iif`: every posted entry from the first through `as_of`, never a period), `tax_summary` (see "Tools — tax categories"), `fixed_asset_register` - (as of a day; see "Tools — fixed assets"). + (as of a day; see "Tools — fixed assets"), `ar_aging`, `ap_aging` + (as of a day: columns `number,party,date,due_date,terms,total,paid, + discounted,outstanding,bucket,days_overdue`, one row per open + document, then a tie-out line `TOTAL,,,,,,,,<Σ outstanding>,GL + ,difference 0.00`; B-23, 8.3.10). - Formats: `pdf` and `xlsx` for `profit_and_loss`, `balance_sheet`, `cash_flow`, `trial_balance`, `members_equity`, `tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for @@ -2086,21 +2090,37 @@ snapshots; see plan 8.2 S4. - `warnings` names an expense account it created with no tax category; `notes` one per finished or abandoned project a line names (none for on-spec: a cost of on-spec work is an expense like any other). -- `books.receive_payment { document_id, date, amount, cash_account, - instrument?, reference? }` → `{ entry_id, remaining, status }`. Posts - Dr Cash / Cr A/R, the receivable line tagged with the invoice (B-38), - neither line tagged with a project (balance-sheet only: the invoice's - lines carry its projects). `instrument` (id or label) is tagged on the - entry in the same call; `reference` (one line, at most 200 characters: - a charge id, a trace number) goes into its description. `remaining` - and `status` (open, partial, paid, written_off, void, overpaid, - unattached) are the invoice's derived figures after it. The books - record payments; they never collect them (D-45). -- `books.pay_bill { document_id, date, amount, cash_account, instrument?, - reference? }` → `{ entry_id, remaining, status }`. Posts Dr A/P / Cr - Cash, the payable line tagged with the bill, neither line tagged with a - project; `instrument` and `reference` as for `receive_payment` (a card - payment is left out of 1099-NEC, D-40). +- `books.receive_payment { date, deposit_to | cash_account, instrument?, + reference?, invoice | document_id, amount? | applications: [{ invoice, + amount, discount?, discount_reason? }], fee?: { amount, account?, + vendor? }, paid_in_full_marking? }` → `{ entry_id, documents: [{ ref, + number?, outstanding, status }], deposited, remaining?, status?, + warnings? }`. Settles one or several invoices of one customer in one + entry (B-42): Dr the deposit account the cash less the fee, Dr the + payment-processing-fees account the fee, Dr sales allowances the + discounts, Cr A/R each invoice's cash and discount, every receivable + line tagged with its invoice (payment lines in application order, then + the discount lines). The `amount`s are cash only; a discount goes beside + one. A discount at most the invoice's terms allow and dated by their + discount date needs no reason; a larger or later one needs + `discount_reason`. `fee.vendor` (a processor the books know as a vendor) + is named in the entry's description; only cash movements count toward a + 1099, so the fee is not a payment to the processor. `deposited` is the + cash less the fee (A-4's gross presentation); `remaining` and `status` + repeat the single document's figures. An overpayment is refused with the + excess and the remedy (a plain entry to 2300 Customer Deposits). + `paid_in_full_marking: true` adds a UCC §3-311 warning and records + nothing else. `document_id` and `cash_account` are the older names of + `invoice`/`bill` and `deposit_to`/`paid_from`. The books record + payments; they never collect them (D-45). +- `books.pay_bill { date, paid_from | cash_account, instrument?, + reference?, bill | document_id, amount? | applications: [{ bill, amount, + discount?, discount_reason? }] }` → as `receive_payment`, per bill and + one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer + account the cash, Cr purchase discounts the discounts. A discount on a + bill with an asset or prepaid line is refused with its remedy (record + the bill net, or reclassify Dr 6995 / Cr the asset, D-49); a processor's + fee is refused on a bill. - `books.void_document { kind, id, date, reason }` → `{ entry_id, status }`. Voids an invoice or bill (`kind` `invoice` or `bill`) that should never have existed by reversing its own entry with the reason; it keeps its @@ -2156,9 +2176,22 @@ snapshots; see plan 8.2 S4. there is no unattached line and nothing needing attention. `books.verify` checks the same identity and `books.digest` lists its problems (`control_problems`). -- `books.ar_aging { as_of? }` → `{ as_of, rows: [{ name, current, days_30, - days_60, days_90, days_90_plus, total }] }` (`as_of` omitted: today). -- `books.ap_aging { as_of? }` → same, per vendor (`as_of` omitted: today). +- `books.ar_aging { as_of?, detail? }` → `{ as_of, rows: [{ name, + current, days_30, days_60, days_90, days_90_plus, total }], documents?: + [{ ref, number?, party, date, due_date, terms?, total, paid, + discounted, outstanding, bucket, days_overdue }], control: { account, + balance, documents_total, unattached, difference } }` (`as_of` omitted: + today). `detail: true` adds one row per open invoice; `control` is the + receivables account's tie-out (B-23, B-38 INVARIANT 1), `difference` + always `0.00`. +- `books.ap_aging { as_of?, detail? }` → same, per vendor and bill. +- `books.bills_due { through?, as_of? }` → `{ as_of, through, bills: + [{ ref, vendor, vendor_ref?, due_date, outstanding, discount_by?, + max_discount?, pay_by_discount_date_amount? }], by_date: [{ date, + amount }] }`. Every open bill due on or before `through` (omitted: + today), due date first, with its terms' early-payment discount when it + is still open (`discount_by`, the most off, what to pay to take it); + `by_date` is the running total of what falls due through each date. ## Tools — foreign exchange diff --git a/docs/parity.md b/docs/parity.md index 0102219..6fb6ff3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -46,6 +46,9 @@ No other statuses. | 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) | — (`just oracle`) | partial | live: `tests/oracle_gnucash.rs` runs 200 random books through GnuCash 5.10 in a pinned container, 15,800 exact comparisons, weekly in CI (docs/oracle.md Layer 5); open: fixture books diffed as `snapshot()` output, and the machine-readable catalog with CI enforcing this floor | | Invoice sent tracking (who, how, to whom, when, original or reminder, the channel's reference) | Business → Invoices (Xero's "Sent"; QBO's email) | done | P4b S5; records, never sends (A-4); a send changes no figure; `invoice.sent` under `documents_v2` (D-52) | +| 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. @@ -151,3 +154,12 @@ tracking also feeds Needs attention: an issued invoice nobody marked sent (past `invoicing.unsent_days`) and an overdue invoice not reminded within `invoicing.reminder_days` are one list in the viewer and in `books.start`, each offering `books.mark_sent` (step 4; U0b's one engine). + +### 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). diff --git a/docs/spec/rules.md b/docs/spec/rules.md index f852c51..63eb553 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -3119,3 +3119,98 @@ starts from empty books (legacy lines are covered by the identity coding of `DocRef`), and an account becoming a control account by its first document, holding lines from before, is outside it (those lines are listed as unattached). + +## B-42 A settlement is exact and names its lines + +**bookspec:** `BS-arap-6` + +**Status:** CONVENTION (a payment that settles several documents at once is +still one balanced entry, and each control line carries its document; the +subledger tie-out is B-38's); CHOICE (the gross method for an +early-payment discount, A-8; the processor's fee posted gross, A-4; D-49's +refusal to apportion a discount across a bill's asset lines). ASC +606-10-32-5 to 32-9 are the strict treatment of an expected discount; +this company's size makes the gross method immaterial (A-8). Sources are +cited and not yet vendored, so this rule keeps the CONVENTION label (the +Open list in `README.md`). + +1. **One party, one kind.** A settlement applies to one or more invoices + of one customer, or one or more bills of one vendor, never a mix, and + never the same document twice. Deposits: Σ cash + Σ discounts is what + it applies to the documents; Σ cash − fee is what reaches the deposit + account (`kernel::settle`). The arithmetic is exact: no epsilon, no + rounding, and a fee above the cash is refused. +2. **Bounds.** Each application's cash + discount is at most what its + document owes on every day from the settlement's date on + (`payment_room`), so no day is overpaid. A payment is never dated + before its document, and never in a closed period. +3. **Discounts.** A discount is at most `kernel::discount_max(total, + percent_bp)` for the document's terms and is dated on or before + `terms.discount_by(date)`; outside that it needs a reason, journaled + with the payment (a vendor honouring a late discount). Terms that + state no discount are the same: any discount needs a reason. A discount + on a bill whose lines include an asset or prepaid account is refused + with D-49's remedy: record the bill net of the discount, or reclassify + Dr 6995 Purchase Discounts / Cr the asset. +4. **The fee.** A processor's fee is a customer payment's only. It posts + gross: Dr the processing-fee expense, Dr the deposit account the net, + Cr accounts receivable the gross. Only cash movements count toward a + 1099, so the fee is not a payment to the processor. +5. **Lines and journal.** The entry's rows are the payment lines in + application order, then the discount lines, then the fee line, then + the cash line; a new payment event names the line it tags (`line`) and, + when it took one, its discount and discount line (`discount`, + `discount_line`, `discount_reason`), and the fee's on the first + application's event (`fee`, `fee_line`). Replay tags exactly those + lines; a legacy event with no `line` takes the first untagged control + line for its amount (B-38 item 1). A line replay cannot match stays + untagged and the control check lists it: replay never refuses (B-28). +6. **Exports.** An aging as of a day lists one row per open document with + its number, party, dates, terms, total, paid, discounted, outstanding, + bucket and days overdue, then a tie-out line against its control + account (B-23, B-38 INVARIANT 1). The CPA package and `books.export` + write the same CSV. + +```text +GOAL: a settlement is exact and names its lines. +SOURCE: CONVENTION; ASC 606-10-32-5 to 32-9 noted; CHOICE: the gross + method (A-8), the fee's gross presentation (A-4). +INVARIANTS: + 1. Σ cash + Σ discounts = Σ applications; deposit = Σ cash − fee + (kernel::settle); one party per settlement; one application per + document. + 2. Each application's cash + discount ≤ what the document owes on every + day from the payment's date on. + 3. Each new payment event names the line it tags; replay tags that line; + a legacy event without one takes the first untagged control line for + its amount. + 4. A discount on a bill with an asset or prepaid line is refused. +SCENARIOS: + 1. Two invoices of one customer, 300 and 80, paid 380 by one deposit: + two payment lines, each tagged, one cash line. + 2. "2/10 net 30" bill 100, paid 98 within ten days: Dr A/P 98 (payment) + and 2 (discount), Cr purchase discounts 2, Cr bank 98. + 3. The same discount after the tenth day without a reason: refused, + naming discount_reason; with one: taken and journaled. + 4. An invoice 100 paid through a processor keeping 3: Dr bank 97, Dr + processing fees 3, Cr A/R 100. + 5. A discount on a bill whose line is equipment: refused with the + record-net or reclassify remedy (D-49). +``` + +**Enforced:** `kernel::settle`, `Ledger::{settle, settlement_lines, +check_discount}`, `arap::{Application, Fee, SettleParams, SettlementRow}`, +`Ledger::{replay_invoice_payment_at, replay_bill_payment_at}`, +`Store::settle`, the aging builders (`Ledger::aging_documents`, +`ledger_package::{aging_csv, aging_statement}`). **Checked:** `ledger-core` `tests/spec_documents.rs` +(`bill_on_terms_with_discount`, `late_discount_needs_a_reason`, +`discount_on_an_asset_bill_is_refused_with_the_remedy`, +`a_fee_posts_gross_to_receivables_and_net_to_the_bank`, +`a_settlement_names_its_lines_and_refuses_a_document_twice`), +`tests/kernel_bridge.rs::settle_agrees`; Kani `settle_is_exact`; +`ledger-store` `tests/arap_standing.rs` +(`a_settlement_names_its_lines_and_replays`, `settlement_waits_for_documents_v2`, +`legacy_payment_events_keep_the_positional_rule`), `tests/arap_props.rs`; +`ledger-cli` `tests/documents.rs`; `ledger-package`'s aging tie-out test. +The Lean step is `Documents.Step.document` with every control line tagged +(`Documents.settle_unattached`); no new `Step` constructor. diff --git a/docs/testing.md b/docs/testing.md index f5bf593..3eba438 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3114,3 +3114,80 @@ predicates in `ledger-package::attention`: `invoice.number.is_some()` `checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the `sends.max()` last send, and the `reminder_days` comparison (`>`), plus each item's target (`Target::Invoice`) and `books.mark_sent` step args. + +## P4b S6: paying on terms (2026-10-27) + +rules.md B-42; plan 8.2 P4b S6. One payment settles several documents of +one party, with the early-payment discounts their terms allow, the +processor's fee posted gross, a bills-due list, the agings' detail and +tie-out, and the aging exports. + +- **Core (`crates/ledger-core/tests/spec_documents.rs`):** + `bill_on_terms_with_discount` (the gross-method entry for a bill on + "2/10 net 30", and a 3% discount refused without a reason, taken with + one), `late_discount_needs_a_reason` (after the discount date, and the + asset-line refusal's unchanged books), + `discount_on_an_asset_bill_is_refused_with_the_remedy` (D-49: the code + and both remedies), `a_fee_posts_gross_to_receivables_and_net_to_the_bank` + (Dr bank 97, Dr 6060 3, Cr A/R 100), + `a_settlement_names_its_lines_and_refuses_a_document_twice` (control + lines in application order; one document twice refused). + `spec_cash_flow.rs::equipment_bill_payment_is_investing_cash` keeps + D-35 through a settlement. +- **Kernel:** `kernel_bridge.rs::settle_agrees`; Kani + `settle_is_exact` (a nonzero discount and a nonzero fee covered). +- **Store (`crates/ledger-store/tests/arap_standing.rs`):** + `a_settlement_names_its_lines_and_replays` (one entry, two invoices, a + discount and a fee; each event's `line`, `discount_line` and `fee_line` + point at the rows the plan posts; replay equals live); + `settlement_waits_for_documents_v2` (refused with the feature remedy and + no journal line, then accepted after the upgrade); + `legacy_payment_events_keep_the_positional_rule` (the old fixture's + events carry no `line`, and replay still tags in order). + `arap_props.rs`'s history gains `Settle` (two documents, a discount and, + on invoices, a fee), so `subledger_equals_control`, the bounds and + replay-equals-live run over settlements. +- **Store's older shapes:** `tests/store.rs::books_written_under_older_rules_still_open` + writes a payment event of S6's shape by hand through `JournalWriter` + and opens the books, the invoice reading paid and discounted. +- **Over the wire (`crates/ledger-cli/tests/documents.rs`):** + `one_payment_settles_two_invoices`, `late_discount_needs_a_reason`, + `a_fee_posts_gross_to_receivables_and_net_to_the_bank`, + `card_paid_bill_is_not_1099_nec`, + `multi_document_payment_counts_once_for_1099_and_party_totals`, + `overpayment_refused_with_the_excess`, + `bills_due_orders_by_due_and_discount` (the list and its running total; + no terms, so no discount fields), `the_aging_export_ties_out` (the CSV + header, row and `TOTAL` tie-out). +- **CPA package (`crates/ledger-package/tests/package.rs`):** + `the_aging_csvs_tie_to_the_control_check`. +- **Viewer (`crates/ledger-gui/tests/invoices.rs`):** + `the_list_shows_the_ledgers_figures` re-renders the Money out list's + screenshot with the new "Discount by" column (blank until S4.3 + journals terms). +- **Not yet possible through the MCP (depends on S4.3/S4.4):** + `bill_on_terms_with_discount` and `bills_due_orders_by_due_and_discount`'s + discount half need a journaled `terms` field (S4.3) and + `equipment_bill_payment_is_investing_cash` needs a bill line's account + in `books.create_bill` (S4.4). The core tests named for them stand in + until those land; see the unit's report. +- **Older books (8.0.6):** the pre-S1 fixture + `tests/fixtures/p4-arap-before` is also pre-S6 and holds payments; its + golden figures still hold (`legacy_reversal_now_counts`), and S6 moves + no figure. Its journal has no declared `documents_v2`, so the store test + above proves the refusal and the upgrade. + +**For P12 (mutation targets):** `kernel::settle`'s length check, each +negative check, the fee-above-cash test and both checked adds; +`Ledger::settlement_lines`'s kind/party checks, the duplicate-document +check, the per-application `cash + discount` bound, the discount window +(`<= max`, `<= by`, "has a reason"), the asset-bill loop, the fee-on-bill +refusal and the row order (payment lines, discounts, fee, cash); +`check_discount`'s `within` conjunction and its `because` arms; +`replay_*_payment_at`'s line/amount/account/side match and the discount +line's tag; the store's `documents_v2` condition (more than one +application, any discount, a fee) and the `line`/`discount_line`/`fee_line` +arithmetic; `bills_due`'s `through` filter, the discount-window test and +the running-total grouping; `aging_documents`'s outstanding filter and +bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the +viewer's `discount_by` and `days_overdue` flags. diff --git a/docs/verification.md b/docs/verification.md index a35ea9e..78cea16 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -544,7 +544,7 @@ come from the kernel: ### Sent tracking (P4b S5) -34. **A send changes no figure.** `Ledger::mark_sent` is a command, so it +35. **A send changes no figure.** `Ledger::mark_sent` is a command, so it is a `Step` in the model (AGENTS.md's formal-methods bar; the unit's "no Lean change" phrase was overruled by it, and says so in its As built block). `Documents.lean`'s `Books` gains `sends`, and @@ -560,6 +560,25 @@ come from the kernel: `a_send_changes_no_figure`, `mark_sent_on_void_refused`, `mark_sent_waits_for_documents_v2` +### Settlements (P4b S6) + +36. **A settlement is exact.** The applications' total is Σ cash + Σ + discounts and the deposit is Σ cash − fee, with exact arithmetic; a + fee above the cash, a negative amount, or arrays of different lengths + is refused (None), never wrapped or rounded. The core's posting is a + document step: every control line a settlement posts carries its + document, so `Documents.control_agrees` covers it and no new `Step` + constructor is needed. + - Kani `settle_is_exact` (at i32, fixed arrays of three applications, + a nonzero discount and a nonzero fee covered) · bridge + `settle_agrees` · tests `bill_on_terms_with_discount`, + `late_discount_needs_a_reason`, + `discount_on_an_asset_bill_is_refused_with_the_remedy`, + `a_fee_posts_gross_to_receivables_and_net_to_the_bank`, + `a_settlement_names_its_lines_and_refuses_a_document_twice` · Lean + `Documents.settle_posts_a_document_step`, + `Documents.settle_unattached`. + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, @@ -757,7 +776,9 @@ headline theorems) and `just prove-slow` 467 s (8 harnesses, the slowest P4b S4.1's two harnesses were measured on 2026-10-26 (a busy machine); the rest are the 2026-10-22 run. `discount_max_is_bounded`'s 163 s is -Kani's verification time (it runs in `prove-slow`). +Kani's verification time (it runs in `prove-slow`). P4b S6 adds +`settle_is_exact` to `prove` and two Lean theorems to the audit +(`Documents.settle_posts_a_document_step`, `Documents.settle_unattached`). Every harness, the law it proves, the recipe that runs it, and its time in that quiet run (`prove`: each harness's verification time; `prove-slow`: @@ -824,4 +845,5 @@ each harness's whole run, its compile of about 50 s included): | `line_amount_is_exact` | 33 | prove | 6 | | `bounds_check_matches_definition` | 34 | prove | (not in the 2026-10-22 run; P4b S1, 2026-10-24) | | `bounds_kept_is_the_definition_on_books_in_bounds` | 34 | prove | (as above) | +| `settle_is_exact` | 36 | prove | 1 | | `kani_symbolic_copy_canary` | (the checker: "Trusting the checker") | prove | 3 | diff --git a/plan/served.md b/plan/served.md index 55a0134..8894284 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1383,7 +1383,15 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `Ledger::settle` and its tests, the payment events' new fields and arms, `books.bills_due`, the aging exports; touches the 8.0.3 files, `crates/ledger-mcp/src/tools/documents.rs` (after S4.4; coordinate with the S4 engineer on `tools/documents.rs`: S6 adds its handlers in a second file `tools/settlements.rs` if S4.4 is still open). 6. **Tests and proofs:** named above; Kani `settle_is_exact`; Lean: none new (the posting step is `Step.document` with more lines; `control_agrees` covers it because every control line is tagged). 7. **Done when:** (1) the tests pass; (2) `several_payment_events_on_one_entry_tag_its_lines_in_order` still passes (the legacy rule); (3) the 1099 worksheet and party totals count a two-invoice payment once (`multi_document_payment_counts_once_for_1099_and_party_totals`); (4) the CPA package's aging CSVs tie to `control_check` on the demo books; (5) 8.0.6 in the PR, no golden figure moved; (6) `just check`, `just prove` green. -8. **As built:** none yet. +8. **As built** (2026-10-27, `S6`): the steps above are built except the departures below. + - **The handlers live in `crates/ledger-mcp/src/tools/settlements.rs`**, not `tools/documents.rs` (the EM approved this group, because S4.4 works in `documents.rs`). `books.receive_payment`, `books.pay_bill`, `books.ar_aging` and `books.ap_aging` moved there; `books.bills_due` is new. `docs/architecture.md` names the group. + - **`Ledger::settle` is a public command**, as Build 2 asks, so `Store::settle` calls it; its posting is still `Step.document` (every control line carries its document), and Lean gained `Documents.settle_posts_a_document_step` and `Documents.settle_unattached` instead of a new `Step` constructor (plan 8.2 S6, Tests and proofs: "the posting step is `Step.document` with more lines"). + - **Terms cannot yet be journaled on a document** (S4.3 owns `invoice.created`/`bill.created`'s `terms`). `Ledger::settle` reads `doc.terms` wherever it is set, and the core tests (`bill_on_terms_with_discount`, `late_discount_needs_a_reason`) build documents through `create_invoice_with`/`create_bill_with`; the over-the-wire `bill_on_terms_with_discount` and the discount half of `bills_due_orders_by_due_and_discount` wait for S4.3. A bill line's account is likewise S4.4's MCP field, so `equipment_bill_payment_is_investing_cash` is a core test (`spec_cash_flow.rs`) until then. + - **A fee's processor is recorded in the entry's description, not as the entry's vendor tag.** Ledger party/vendor tags are entry-wide, not line-level; tagging the entry with the processor would move the customer's party total and muddy 1099-NEC. `fee.vendor` is resolved and named in the description, and only cash movements count toward a 1099. + - **`confirmed` is not added**: P4 owns preview/confirm for every mutating tool, and adding a preview now would duplicate that sweep. The older `document_id`/`cash_account` names still work beside `invoice`/`bill` and `deposit_to`/`paid_from`. + - **No schema change**: discounts and fees are tagged ledger lines and payment-event fields, so the projection needs no column and schema 18 is not taken. + - **No bookspec examples yet**: `payment-terms-early-discount` needs S4.3's journaled terms; `arap-settlement-exact` and `processor-fee-gross-presentation` are recorded here as the next step for the bookspec unit, not written. + - **8.0.6:** item 1 is in `books_written_under_older_rules_still_open`; item 2 is the pre-S1 (therefore pre-S6) fixture `tests/fixtures/p4-arap-before`, whose figures still hold; item 3 is `settlement_waits_for_documents_v2`; item 4 is `tests/format.rs` unchanged (no new event type) plus `format_upgrade.rs` unchanged (the `documents_v2` setting is S4.2's); item 5 is the same fixture's `figures.json` and `legacy_reversal_now_counts` (no figure moved); item 6 is in the PR/report. #### P4b S7: bad debts (`write_off_invoice`) diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index dd05949..6387371 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -92,6 +92,8 @@ open Wanderbooks #print axioms Documents.control_agrees #print axioms Documents.control_agrees_through #print axioms Documents.Step.document_unattached +#print axioms Documents.settle_posts_a_document_step +#print axioms Documents.settle_unattached #print axioms Documents.Step.reverse_moves #print axioms Documents.reverse_twice #print axioms Documents.Step.markSent_changes_no_figure diff --git a/verify/lean/Wanderbooks/Documents.lean b/verify/lean/Wanderbooks/Documents.lean index 17ed148..3437709 100644 --- a/verify/lean/Wanderbooks/Documents.lean +++ b/verify/lean/Wanderbooks/Documents.lean @@ -221,6 +221,21 @@ theorem Step.document_unattached (B : Books) (e : List TLine) (h : Tagged B.cont simp [hn, onAcct, hacc] · simp [hn] +/-- **A settlement is a document step**: its entry's every control line +carries its document (a payment or a discount), so the books' rules for +any entry whose control lines are tagged cover it; no new `Step` +constructor is needed (plan P4b S6; rules.md B-42). -/ +theorem settle_posts_a_document_step (B : Books) (e : List TLine) + (h : Tagged B.control e) : Step B { B with lines := B.lines ++ e } := + Step.document B e h + +/-- **A settlement never moves what is unattached** on a control account: +every control line it posts is its document's (B-42, from +`Step.document_unattached`). -/ +theorem settle_unattached (B : Books) (e : List TLine) (h : Tagged B.control e) (a : Nat) + (ha : a ∈ B.control) : unattached a (B.lines ++ e) = unattached a B.lines := + Step.document_unattached B e h a ha + /-- **A reversal moves each document by exactly the opposite of its lines it mirrors** (B-38 INVARIANT 3), and what is unattached likewise: only reversing a legacy hand line changes it. -/