merge: P4b S6, paying on terms (branch S6, 6c4480cd)

The third of the paused branches: Ledger::settle with discounts and the
processor's fee (kernel::settle, Kani settle_is_exact, Lean
settle_posts_a_document_step), books.receive_payment and books.pay_bill
with applications, books.bills_due, the agings' detail and tie-out, the
aging exports and the CPA package's aging CSVs, the handlers in
tools/settlements.rs. Twelve files conflicted with the S4.4 and S5
merges, resolved as plan 8.0.2 says: both routers; both test appends
(the shared match arm in arap_standing.rs spliced into both tests; the
shared `.unwrap()` in store.rs); the scope table at 124; one
invoices-and-bills handbook (S5's send step 1 with S6's steps 2, 3, 5
and procedures) registered once; docs/mcp-interface.md with S4.4's
create docs and S6's payment docs; S5's law renumbered 35 and S6's 36
(S1's is 34); the parity rows and sections of both. S6's wire tests
create their bills as S4.4's contract now requires (confirmed, the
vendor's number, a line account, the vendor's invoice uploaded) and live
in their own module beside S4.4's tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:26:40 -06:00
commit be58059830
45 files changed
+3949 -520

No files matched your search

+417
View File
@@ -795,3 +795,420 @@ fn mark_sent_records_who_and_how() {
"a send changes no figure"
);
}
// P4b S6: paying on terms (merged from branch S6), in its own module so its
// helpers keep their names beside S4.4's.
mod paying_on_terms {
use super::common::answer_of;
use super::*;
/// Call a tool and unwrap its answer.
fn ok(w: &mut Wire, tool: &str, args: Value) -> Value {
answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{tool}: {e}"))
}
/// The refusal's text.
fn err(w: &mut Wire, tool: &str, args: Value) -> String {
answer_of(&w.call(tool, args)).expect_err(&format!("{tool} was accepted"))
}
struct Setup {
_books: Books,
wire: Wire,
customer: u64,
vendor: u64,
bills: u32,
}
/// Bank 1000, card 2050, capital 3000, a customer and a vendor, then a
/// session.
fn setup(tag: &str) -> Setup {
let books = Books::new(tag);
let mut wire = books.session("documents");
ok(
&mut wire,
"books.add_account",
json!({"code": "1000", "name": "Bank", "account_type": "asset"}),
);
ok(
&mut wire,
"books.add_account",
json!({"code": "2050", "name": "Card", "account_type": "liability"}),
);
ok(
&mut wire,
"books.add_account",
json!({"code": "3000", "name": "Capital", "account_type": "equity"}),
);
ok(
&mut wire,
"books.add_account",
json!({"code": "6999", "name": "General Expenses", "account_type": "expense"}),
);
let customer = ok(
&mut wire,
"books.add_customer",
json!({"name": "Fernhill Labs"}),
)["id"]
.as_u64()
.unwrap();
let vendor = ok(
&mut wire,
"books.add_vendor",
json!({"name": "Tobin Harlow Supply"}),
)["id"]
.as_u64()
.unwrap();
Setup {
_books: books,
wire,
customer,
vendor,
bills: 0,
}
}
impl Setup {
fn invoice(&mut self, date: &str, due: &str, amount: &str) -> u64 {
ok(
&mut self.wire,
"books.create_invoice",
json!({"party_id": self.customer, "date": date, "due_date": due,
"lines": [{"description": "Consulting", "amount": amount}],
"confirmed": true}),
)["id"]
.as_u64()
.unwrap()
}
fn bill(&mut self, date: &str, due: &str, amount: &str) -> u64 {
// Under documents_v2 (P4b S4.4) a bill names the vendor's invoice
// number, each line's account, and carries the vendor's invoice.
self.bills += 1;
let file = self.wire.upload(
format!("vendor invoice TH-{}", self.bills).as_bytes(),
"bill.pdf",
"application/pdf",
"receipt",
);
ok(
&mut self.wire,
"books.create_bill",
json!({"party_id": self.vendor, "date": date, "due_date": due,
"vendor_ref": format!("TH-{}", self.bills),
"lines": [{"description": "Paper", "amount": amount, "account": "6999"}],
"upload_id": file, "confirmed": true}),
)["id"]
.as_u64()
.unwrap()
}
fn line(&mut self, entry: u64, code: &str) -> (String, String, String) {
let e = ok(
&mut self.wire,
"books.get_entry",
json!({"entry_id": entry}),
);
for l in e["lines"].as_array().unwrap() {
if l["code"] == json!(code) {
return (
l["side"].as_str().unwrap().to_string(),
l["amount"].as_str().unwrap().to_string(),
code.to_string(),
);
}
}
panic!("{code} not in entry {entry}: {e}");
}
}
/// One deposit settles two invoices of one customer: one entry, one
/// receivable line per invoice, each tagged with its own invoice, and the
/// output names both documents (B-42 INVARIANTS 1 and 3).
#[test]
fn one_payment_settles_two_invoices() {
let mut s = setup("settle-two");
let first = s.invoice("2026-02-01", "2026-03-01", "500.00");
let second = s.invoice("2026-02-05", "2026-04-30", "80.00");
let out = ok(
&mut s.wire,
"books.receive_payment",
json!({"date": "2026-02-10", "deposit_to": "1000",
"reference": "one deposit",
"applications": [
{"invoice": first, "amount": "300.00"},
{"invoice": second, "amount": "80.00"}
]}),
);
assert_eq!(out["deposited"], json!("380.00"));
let refs: Vec<&str> = out["documents"]
.as_array()
.unwrap()
.iter()
.map(|d| d["ref"].as_str().unwrap())
.collect();
assert_eq!(
refs,
[format!("invoice:{first}"), format!("invoice:{second}")]
);
assert_eq!(out["documents"][0]["outstanding"], json!("200.00"));
assert_eq!(out["documents"][1]["status"], json!("paid"));
// The entry: one deposit line, two receivable lines.
let entry = out["entry_id"].as_u64().unwrap();
let e = ok(&mut s.wire, "books.get_entry", json!({"entry_id": entry}));
let lines = e["lines"].as_array().unwrap();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0]["side"], json!("credit"));
assert_eq!(lines[0]["amount"], json!("300.00"));
assert_eq!(lines[1]["side"], json!("credit"));
assert_eq!(lines[1]["amount"], json!("80.00"));
// Each invoice's timeline shows its own payment from this entry.
for (id, paid) in [(first, "300.00"), (second, "80.00")] {
let d = ok(
&mut s.wire,
"books.document",
json!({"ref": format!("invoice:{id}"), "as_of": "2026-12-31"}),
);
let payment = d["timeline"]
.as_array()
.unwrap()
.iter()
.find(|t| t["kind"] == json!("payment"))
.unwrap();
assert_eq!(payment["entry_id"].as_u64().unwrap(), entry);
assert_eq!(payment["amount"], json!(format!("-{paid}")));
}
}
/// A discount outside the document's terms needs a reason; with one the
/// payment is taken and recorded (B-42 INVARIANT 2; the terms are S4.3's
/// to journal, so this bill states none).
#[test]
fn late_discount_needs_a_reason() {
let mut s = setup("late-discount");
let bill = s.bill("2026-02-01", "2026-03-03", "100.00");
let applications = |reason: Option<&str>| {
let mut a = json!([{"bill": bill, "amount": "98.00", "discount": "2.00"}]);
if let Some(reason) = reason {
a[0]["discount_reason"] = json!(reason);
}
a
};
let why = err(
&mut s.wire,
"books.pay_bill",
json!({"date": "2026-02-05", "paid_from": "1000", "applications": applications(None)}),
);
assert!(why.contains("needs a reason"), "{why}");
assert!(why.contains("discount_reason"), "{why}");
ok(
&mut s.wire,
"books.pay_bill",
json!({"date": "2026-02-05", "paid_from": "1000",
"applications": applications(Some("the vendor honoured it late"))}),
);
}
/// A fee posts gross (A-4): the deposit gets the net, the fee its expense,
/// and A/R is credited the gross (B-42 INVARIANT 1).
#[test]
fn a_fee_posts_gross_to_receivables_and_net_to_the_bank() {
let mut s = setup("fee-gross");
let invoice = s.invoice("2026-02-01", "2026-03-01", "100.00");
let out = ok(
&mut s.wire,
"books.receive_payment",
json!({"date": "2026-02-05", "deposit_to": "1000",
"invoice": invoice, "amount": "100.00",
"fee": {"amount": "3.00"}}),
);
assert_eq!(out["deposited"], json!("97.00"));
assert_eq!(out["status"], json!("paid"));
let entry = out["entry_id"].as_u64().unwrap();
assert_eq!(
s.line(entry, "1200"),
("credit".into(), "100.00".into(), "1200".into())
);
assert_eq!(
s.line(entry, "6060"),
("debit".into(), "3.00".into(), "6060".into())
);
assert_eq!(
s.line(entry, "1000"),
("debit".into(), "97.00".into(), "1000".into())
);
}
/// A bill paid by card is excluded from 1099-NEC (D-40): the worksheet
/// counts it as card, not as paid.
#[test]
fn card_paid_bill_is_not_1099_nec() {
let mut s = setup("card-1099");
ok(
&mut s.wire,
"books.add_instrument",
json!({"kind": "business_card", "label": "Visa", "last4": "4242", "account": "2050"}),
);
let bill = s.bill("2026-02-01", "2026-03-03", "100.00");
ok(
&mut s.wire,
"books.pay_bill",
json!({"date": "2026-02-05", "paid_from": "2050", "bill": bill, "amount": "100.00",
"instrument": "Visa"}),
);
let worksheet = ok(
&mut s.wire,
"books.vendor_1099",
json!({"year": 2026, "cash_accounts": ["1000"]}),
);
let line = worksheet["vendors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["vendor_id"] == json!(s.vendor))
.unwrap();
assert_eq!(line["paid"], json!("0.00"), "{worksheet}");
assert_eq!(line["excluded_card"], json!("100.00"), "{worksheet}");
}
/// Two bills paid in one entry count once toward 1099s and the party's
/// totals (B-42; 8.3.16's multi-document risk).
#[test]
fn multi_document_payment_counts_once_for_1099_and_party_totals() {
let mut s = setup("multi-1099");
let first = s.bill("2026-02-01", "2026-03-03", "100.00");
let second = s.bill("2026-02-02", "2026-03-04", "200.00");
ok(
&mut s.wire,
"books.pay_bill",
json!({"date": "2026-02-05", "paid_from": "1000",
"applications": [
{"bill": first, "amount": "100.00"},
{"bill": second, "amount": "200.00"}
]}),
);
let worksheet = ok(
&mut s.wire,
"books.vendor_1099",
json!({"year": 2026, "cash_accounts": ["1000"]}),
);
let line = worksheet["vendors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["vendor_id"] == json!(s.vendor))
.unwrap();
assert_eq!(line["paid"], json!("300.00"), "{worksheet}");
let parties = ok(&mut s.wire, "books.parties", json!({"year": 2026}));
let party = parties["parties"]
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == json!("Tobin Harlow Supply"))
.unwrap();
assert_eq!(party["paid"], json!("300.00"), "{parties}");
}
/// An overpayment is refused showing what the document owes and the
/// remedy for the excess (A-12, D-49).
#[test]
fn overpayment_refused_with_the_excess() {
let mut s = setup("overpayment");
let invoice = s.invoice("2026-02-01", "2026-03-01", "100.00");
let why = err(
&mut s.wire,
"books.receive_payment",
json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "150.00"}),
);
assert!(why.contains("150.00"), "{why}");
assert!(why.contains("100.00"), "{why}");
assert!(why.contains("excess"), "{why}");
assert!(why.contains("2300"), "{why}");
}
/// The bills-due list orders by due date and gives the running total
/// through each date; the agings' detail rows and their tie-out agree with
/// the control check (8.3.10; B-42).
#[test]
fn bills_due_orders_by_due_and_discount() {
let mut s = setup("bills-due");
s.bill("2026-02-01", "2026-02-20", "100.00");
s.bill("2026-02-02", "2026-02-10", "250.00");
s.bill("2026-02-03", "2026-03-10", "400.00");
let due = ok(
&mut s.wire,
"books.bills_due",
json!({"through": "2026-02-28", "as_of": "2026-02-05"}),
);
let dates: Vec<&str> = due["bills"]
.as_array()
.unwrap()
.iter()
.map(|b| b["due_date"].as_str().unwrap())
.collect();
assert_eq!(dates, ["2026-02-10", "2026-02-20"]);
assert_eq!(
due["by_date"],
json!([
{"date": "2026-02-10", "amount": "250.00"},
{"date": "2026-02-20", "amount": "350.00"}
])
);
// The discount fields exist only when the bill's terms give one, which
// S4.3 journals: without them they are absent, not zero.
assert!(due["bills"][0].get("discount_by").is_none());
let aging = ok(
&mut s.wire,
"books.ap_aging",
json!({"as_of": "2026-02-05", "detail": true}),
);
assert_eq!(aging["control"]["difference"], json!("0.00"), "{aging}");
assert_eq!(aging["control"]["documents_total"], json!("750.00"));
let rows = aging["documents"].as_array().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0]["ref"], json!(format!("bill:{}", 2)));
assert_eq!(rows[0]["bucket"], json!("current"));
// A/R is empty and still ties.
let ar = ok(
&mut s.wire,
"books.ar_aging",
json!({"as_of": "2026-02-05", "detail": true}),
);
assert_eq!(ar["control"]["balance"], json!("0.00"));
assert_eq!(ar["control"]["difference"], json!("0.00"));
assert!(ar.get("documents").is_none());
}
/// The aging exports are the same CSV the CPA package carries: one row per
/// document and a tie-out line, and the CPA package's CSVs tie to the
/// control account (8.3.10; ledger-package's test repeats the tie-out).
#[test]
fn the_aging_export_ties_out() {
let mut s = setup("aging-export");
let invoice = s.invoice("2026-02-01", "2026-03-01", "500.00");
ok(
&mut s.wire,
"books.receive_payment",
json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "200.00"}),
);
let out = ok(
&mut s.wire,
"books.export",
json!({"format": "csv", "report": "ar_aging", "as_of": "2026-02-28"}),
);
let csv = s.wire.fetch_text(&out["file"]);
let mut lines = csv.lines();
assert_eq!(
lines.next(),
Some(
"number,party,date,due_date,terms,total,paid,discounted,outstanding,bucket,days_overdue"
)
);
assert!(lines.next().unwrap().contains("500.00"));
let total = lines.next().unwrap();
assert!(total.starts_with("TOTAL,,,,,,,,"), "{total}");
assert!(total.contains("300.00"), "{total}");
assert!(total.contains("GL 1200 300.00"), "{total}");
assert!(total.ends_with("difference 0.00"), "{total}");
}
}
+2
View 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
@@ -887,6 +887,7 @@ fn happy_path(name: &str) -> Mcp {
);
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
m.ok("books.bills_due", json!({"through": "2026-06-30"}));
// P4b S1: a document voided, and the control accounts' tie-out.
let dup = m.ok(
"books.create_invoice",
@@ -1193,10 +1194,14 @@ fn happy_path(name: &str) -> Mcp {
("csv", "general_ledger"),
("json", "members_equity"),
("csv", "tax_summary"),
("csv", "ar_aging"),
("xlsx", "ar_aging"),
("pdf", "ap_aging"),
] {
m.ok(
"books.export",
json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}),
json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31",
"as_of": "2026-12-31"}),
);
}
// The IIF takes as_of (every posted entry from the first through it),