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:
commit
be58059830
45 files changed
+3949
-520
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user