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:
1 parent
78a2385440
commit
57f04dbb0c
17 files changed
+1467
-231
No files matched your search
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user