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.
This commit is contained in:
swaits committed 2026-10-27 12:59:40 -06:00
1 parent 78a2385440
commit 57f04dbb0c
17 files changed
+1467 -231

No files matched your search

+401
View File
@@ -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}");
}
+3 -1
View File
@@ -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",
+6 -1
View File
@@ -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),