From 57f04dbb0c5e77e3e60a2b8f4e3f05ab563e5dcd Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 10:34:35 -0600 Subject: [PATCH] feat(mcp): payments on terms, bills due, and the agings' tie-out (P4b S6) books.receive_payment and books.pay_bill take applications, each document's cash with an optional discount and reason, and a customer payment may carry the processor's fee; the result names every document it settled and what reached the account. books.bills_due lists what to pay and the running total by due date. The agings take detail and return the control account's tie-out, and books.export writes ar_aging/ap_aging. The handlers live in tools/settlements.rs (the EM's split, so S4.4's documents.rs stays free); documents.rs keeps the documents, the void and the control check. docs/mcp-interface.md documents every changed tool and the new one; docs/architecture.md names the group; docs/testing.md records the tests and P12's mutation targets. Old names (document_id, cash_account) still work; the output keeps remaining/status for one document. A fee's processor is named in the description, not tagged as the entry's vendor: ledger tags are entry-wide and tagging would move the customer's party totals and 1099-NEC. The handbook's invoices-and-bills section gains the payment, discount, fee and bounce-replacement procedures (S4.5 can extend it). Over the wire: 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 and the_aging_export_ties_out. Terms cannot yet be journaled on a document (S4.3) and a bill line's account is not an MCP field (S4.4), so bill_on_terms_with_discount, equipment_bill_payment_is_investing_cash and the discount half of bills_due are core tests until those land; the unit's As-built block records this. --- crates/ledger-cli/tests/documents.rs | 401 +++++++++++++ crates/ledger-cli/tests/mcp_journeys.rs | 4 +- crates/ledger-cli/tests/spec_mcp.rs | 7 +- crates/ledger-mcp/handbook/format-upgrade.md | 1 + .../ledger-mcp/handbook/invoices-and-bills.md | 63 ++ crates/ledger-mcp/src/handbook.rs | 4 + crates/ledger-mcp/src/scopes.rs | 3 +- crates/ledger-mcp/src/server.rs | 3 + crates/ledger-mcp/src/server_tests.rs | 3 +- crates/ledger-mcp/src/tools/documents.rs | 193 +----- crates/ledger-mcp/src/tools/reports.rs | 58 +- crates/ledger-mcp/src/tools/settlements.rs | 557 ++++++++++++++++++ crates/ledger-mcp/src/types.rs | 236 +++++++- docs/architecture.md | 7 +- docs/mcp-interface.md | 71 ++- docs/testing.md | 77 +++ plan/served.md | 10 +- 17 files changed, 1467 insertions(+), 231 deletions(-) create mode 100644 crates/ledger-cli/tests/documents.rs create mode 100644 crates/ledger-mcp/handbook/invoices-and-bills.md create mode 100644 crates/ledger-mcp/src/tools/settlements.rs diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs new file mode 100644 index 0000000..776c559 --- /dev/null +++ b/crates/ledger-cli/tests/documents.rs @@ -0,0 +1,401 @@ +//! Paying on terms over the wire (plan P4b S6; rules.md B-42): one payment +//! settling several documents, the early-payment discount, the processor's +//! fee, the bills-due list, the agings' tie-out and the exports. Every +//! call goes through the books server as an agent reaches it (common +//! INVARIANT 1). + +mod common; + +use common::{Books, Wire, answer_of}; +use serde_json::{Value, json}; + +/// 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, +} + +/// 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"}), + ); + 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, + } +} + +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}]}), + )["id"] + .as_u64() + .unwrap() + } + + fn bill(&mut self, date: &str, due: &str, amount: &str) -> u64 { + ok( + &mut self.wire, + "books.create_bill", + json!({"party_id": self.vendor, "date": date, "due_date": due, + "lines": [{"description": "Paper", "amount": amount}]}), + )["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 c8f33cc..decf75f 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -125,7 +125,7 @@ fn mcp_end_to_end() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 122, "expected 122 tools, got {}", tools.len()); + assert_eq!(tools.len(), 123, "expected 123 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); @@ -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 ede855a..f778737 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -878,6 +878,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", @@ -1176,10 +1177,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-mcp/handbook/format-upgrade.md b/crates/ledger-mcp/handbook/format-upgrade.md index 36007b5..9bd5be8 100644 --- a/crates/ledger-mcp/handbook/format-upgrade.md +++ b/crates/ledger-mcp/handbook/format-upgrade.md @@ -6,3 +6,4 @@ - Only the owner can agree to the upgrade, with the owner passphrase: in the app, which asks each time it opens the books, or with `wanderbooks books upgrade ID` in a terminal. No tool can, and you never ask for, take or type the passphrase. Tell the human, in plain words: the books wait for their go-ahead, what was refused, and that saying no keeps the books working as they are. - Before it upgrades, the server makes a backup of the books, their vault and keys included, and proves it whole; a backup that can't be made or proven upgrades nothing. Then the books move to the new format in one step, on the record. - After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours. +- This upgrade is `documents_v2`: invoice numbers, terms and line accounts (S4), sent records (S5), payments that settle several documents with early-payment discounts and a processor's fee (S6), and write-offs (S7). Until it is given, those writes are refused and everything else works as before. diff --git a/crates/ledger-mcp/handbook/invoices-and-bills.md b/crates/ledger-mcp/handbook/invoices-and-bills.md new file mode 100644 index 0000000..138652e --- /dev/null +++ b/crates/ledger-mcp/handbook/invoices-and-bills.md @@ -0,0 +1,63 @@ +# Invoices and bills + +Invoices are what customers owe the company; bills are what it owes +vendors. Every figure on them is read from the ledger lines tagged with +them, so a reversal, a void or a bounced payment is right by +construction: never edit one. + +## 2. Record a customer payment + +- Ask for the date the money arrived, where it landed, and what it paid. + One deposit may settle several invoices of the same customer: pass + `applications` with each invoice and its cash amount. +- Record how it moved (`instrument`) and the processor's or bank's + reference. The books record payments; they never collect them. +- If the customer paid more than the invoices owe, the payment is + refused: record the excess as a plain entry to 2300 Customer Deposits + (no role) and apply it when the work is invoiced (D-49, A-12). +- A check or note marked "paid in full" of a disputed amount earns the + UCC §3-311 warning. Record only the payment; a lawyer or the payer's + agreement settles the rest. + +## 3. Pay a vendor bill + +- Ask for the date, the account it left, and which bills it pays. One + payment may settle several bills of one vendor (`applications`). +- Record the method and the reference. A card payment is tagged as card + and stays out of 1099-NEC (D-40). +- Use `books.bills_due` for what is due and when, with each vendor's + early-payment discount window. + +### Taking an early-payment discount + +- A bill on "2/10 net 30" takes 2% off if paid within ten days. Pass the + discount beside the cash (`discount`); the books post the gross method: + the bill is credited in full, the discount goes to Purchase Discounts + (6995), and the bank gets the rest. +- A discount at most the terms allow and dated on or before their + discount date needs no reason. A larger or later one needs + `discount_reason` (a vendor who honours it anyway); it is journaled. +- A bill with an asset or prepaid line refuses a discount: record the + bill net of the discount, or reclassify with an entry Dr 6995 Purchase + Discounts / Cr the asset account (D-49). Never split the discount across + the asset lines. + +## 5. A processor's fee + +- The books never charge or collect; a processor does, and keeps a fee. + Pass `fee` on `books.receive_payment` (amount, and optionally the + expense account and the processor's vendor). +- The entry is gross: Dr the deposit account the net, Dr the + processing-fee expense the fee, Cr Accounts Receivable the gross (A-4). + Only cash movements count toward a 1099, so the fee is not a payment to + the processor. + +## A replacement payment arrives before a bounce is known + +1. Park it: a plain entry Dr the bank / Cr 2300 Customer Deposits (no + role), described with the customer and what it is for. +2. When the first payment bounces, reverse it (`books.reverse_entry`) + dated the bounce day; it un-pays its invoices. +3. Apply the parked money with `books.receive_payment`, + `deposit_to: "2300"`, dated on or after the bounce, against the + invoices it pays. The 2300 balance returns to zero. diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index beb69b2..8e757a4 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -79,6 +79,10 @@ pub const SECTIONS: &[(&str, &str)] = &[ include_str!("../handbook/parties-and-tins.md"), ), ("1099", include_str!("../handbook/1099.md")), + ( + "invoices-and-bills", + include_str!("../handbook/invoices-and-bills.md"), + ), ("cpa-handoff", include_str!("../handbook/cpa-handoff.md")), ( "settings-and-conventions", diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 2814e5d..69fd038 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 122] = [ +pub const TABLE: [(&str, Needs); 123] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -171,6 +171,7 @@ pub const TABLE: [(&str, Needs); 122] = [ ("books.document", R), ("books.ar_aging", R), ("books.ap_aging", R), + ("books.bills_due", R), ("books.set_fx_rate", W), ("books.run_payroll", P), ("books.remit_payroll_taxes", P), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index dce5b8b..3db8d3d 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -566,6 +566,7 @@ impl BooksServer { + Self::instruments_router() + Self::projects_router() + Self::documents_router() + + Self::settlements_router() + Self::entries_router() + Self::reports_router() + Self::assets_router() @@ -1422,6 +1423,8 @@ mod reports_tools; mod schedules; #[path = "tools/settings.rs"] mod settings_tools; +#[path = "tools/settlements.rs"] +mod settlements; #[path = "tools/tax.rs"] mod tax; diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index 2f12cfb..d2b8622 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -42,7 +42,7 @@ fn annotations_complete() { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 122] = [ + let table: [(&str, Hints); 123] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -150,6 +150,7 @@ fn annotations_complete() { ("books.document", R), ("books.ar_aging", R), ("books.ap_aging", R), + ("books.bills_due", R), ("books.set_fx_rate", W), ("books.run_payroll", D), ("books.remit_payroll_taxes", D), diff --git a/crates/ledger-mcp/src/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs index 7b057c0..f14fbeb 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -1,46 +1,11 @@ //! `books.documents`' group: the A/R and A/P documents (invoices and -//! bills), the payments and voids against them, the read tools -//! (`books.documents`, `books.document`), the control check and the -//! agings. Split whole out of `server.rs` (plan/served.md 8.2 U0a): the -//! tools, their names, descriptions and rules are unchanged -//! (`docs/mcp-interface.md`; rules.md B-38). +//! bills), their voids, the read tools (`books.documents`, +//! `books.document`) and the control check. Split whole out of +//! `server.rs` (plan/served.md 8.2 U0a); the payments and the agings live +//! in `tools/settlements.rs` (P4b S6). use super::*; -/// A payment's method and reference from its parameters (the method by -/// id or label, books.instruments). -fn payment_details( - store: &Store, - p: &PaymentParams, -) -> 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 { @@ -69,14 +34,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 and each project resolved ([`resolve_class`]) before anything is /// written. @@ -208,74 +165,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", @@ -556,78 +445,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 7a4ef1b..68ca38c 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3091,17 +3091,73 @@ pub struct CreateDocumentOut { pub notes: Vec, } +/// 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). @@ -3112,6 +3168,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`. @@ -3119,12 +3203,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`. @@ -3361,6 +3455,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. @@ -3382,6 +3480,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 { @@ -3389,6 +3534,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, } // --------------------------------------------------------------------------- @@ -4719,7 +4929,7 @@ pub struct ReconciliationsOut { /// Parameters for `books.handbook`. #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct HandbookParams { - /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index. + /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, invoices-and-bills, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index. #[serde(default)] pub section: Option, } diff --git a/docs/architecture.md b/docs/architecture.md index 13ce3bb..0687a55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -147,8 +147,11 @@ 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; +- `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 d3734d5..315589c 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -1668,7 +1668,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 @@ -2008,21 +2012,37 @@ S4 changes `books.create_invoice` and `books.create_bill`; see plan 8.2 S4. never tagged; `notes` as for invoices, but none for an on-spec project (a cost of on-spec work is an expense like any other); the feature as for invoices. -- `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 @@ -2060,9 +2080,22 @@ S4 changes `books.create_invoice` and `books.create_bill`; 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/testing.md b/docs/testing.md index c1f814b..7303b5d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3050,3 +3050,80 @@ and `BooksServer::new`'s copy (a dropped field must fail `isolation_label_is_enforced_under_the_unit`); the host's `server_flags` extension and `parse_args`' passthrough of `--isolation` and its optional `--http` value. + +## 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/plan/served.md b/plan/served.md index 65948c9..df3c8d2 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1358,7 +1358,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`)