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

@@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 704ab49c9bea84359475c3afa56c3354275a353670fc71156fba151f846f4f73 # shrinks to first = [Create(false, 0, 1), Create(false, 0, 1)], then = [Close(1), Hand(false, 1)]
cc f678814ec34db3abef40116e17e562fe5abe652c09f12600e19841beaf9d6fe6 # shrinks to first = [Create(false, 0, 13902), Settle { a: 0, b: 0, n: 0, cash: 6676, discount: 551, fee: 0 }], then = []
+80
View File
@@ -71,6 +71,16 @@ enum Op {
Close(u8),
/// A hand line on receivables (true) or payables: always refused.
Hand(bool, i64),
/// One payment settling two documents of one party, the first with a
/// discount and (on invoices) a processor's fee.
Settle {
a: usize,
b: usize,
n: u8,
cash: i64,
discount: i64,
fee: i64,
},
}
fn arb_op() -> impl Strategy<Value = Op> {
@@ -83,6 +93,15 @@ fn arb_op() -> impl Strategy<Value = Op> {
2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)),
1 => (1u8..4).prop_map(Op::Close),
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
2 => (0usize..8, 0usize..8, 0u8..90, 1i64..20_000, 0i64..2_000, 0i64..500)
.prop_map(|(a, b, n, cash, discount, fee)| Op::Settle {
a,
b,
n,
cash,
discount,
fee,
}),
]
}
@@ -220,6 +239,67 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec<DocRef>) -> Result<(
})
.map(drop)
}
Op::Settle { .. } if docs.is_empty() => Ok(()),
Op::Settle {
a,
b,
n,
cash,
discount,
fee,
} => {
let first = docs[a % docs.len()];
let mut second = docs[b % docs.len()];
if second == first && docs.len() > 1 {
second = docs[(b + 1) % docs.len()];
}
let same_kind = matches!(
(first, second),
(DocRef::Invoice(_), DocRef::Invoice(_)) | (DocRef::Bill(_), DocRef::Bill(_))
);
if !same_kind {
// One party and one kind per settlement: not this pair.
Ok(())
} else {
let party = match first {
DocRef::Invoice(id) => s.ledger().get_invoice(id).map(|i| i.customer_id),
DocRef::Bill(id) => s.ledger().get_bill(id).map(|v| v.vendor_id),
}
.unwrap();
let invoices = matches!(first, DocRef::Invoice(_));
let money = cents(cash);
let apps = vec![
ledger_core::Application {
doc: first,
cash: money,
discount: cents(discount),
discount_reason: (discount > 0)
.then(|| format!("a late discount, n {n}")),
},
ledger_core::Application {
doc: second,
cash: money,
discount: Decimal::ZERO,
discount_reason: None,
},
];
s.settle(ledger_core::SettleParams {
date: day(n),
description: "two documents, one payment".into(),
cash_account: k.bank,
party,
applications: apps,
fee: (invoices && fee > 0).then_some(ledger_core::Fee {
amount: cents(fee),
account: None,
vendor: None,
}),
instrument: None,
reference: None,
})
.map(drop)
}
}
Op::Hand(receivables, c) => {
let code = if receivables { "1200" } else { "2000" };
match s.ledger().chart().find_by_code(code).map(|a| a.id) {
+214
View File
@@ -1241,3 +1241,217 @@ fn mark_sent_on_void_refused() {
assert_eq!(s.ledger().get_invoice(inv).unwrap().sends.len(), 1);
s.verify().unwrap();
}
/// A settlement of two invoices, a discount and a processor's fee: one
/// entry whose events name the lines they tag, every figure derived, and
/// the books replaying to the same lines (plan P4b S6; rules.md B-42
/// INVARIANTS 1 and 3).
#[test]
fn a_settlement_names_its_lines_and_replays() {
use ledger_core::{Application, DocRef, Fee, SettleParams};
let dir = test_dir("settlement-lines");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let first = s
.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 7_000)])
.unwrap();
let second = s
.create_invoice(
c,
d(2026, 4, 2),
d(2026, 5, 2),
vec![line("Support", 2_000)],
)
.unwrap();
let from = journal_len(&dir.0);
let entry = s
.settle(SettleParams {
date: d(2026, 4, 10),
description: "One deposit, two invoices".into(),
cash_account: bank,
party: c,
applications: vec![
Application {
doc: DocRef::Invoice(first),
cash: cents(6_000),
discount: cents(1_000),
discount_reason: Some("vendor honoured the early discount late".into()),
},
Application {
doc: DocRef::Invoice(second),
cash: cents(2_000),
discount: Decimal::ZERO,
discount_reason: None,
},
],
fee: Some(Fee {
amount: cents(500),
account: None,
vendor: None,
}),
instrument: None,
reference: Some("ch_42".into()),
})
.unwrap();
let got = since(&dir.0, from);
// The two role accounts created on demand, then the entry, then one
// event per application.
assert_eq!(
got.iter().filter(|(t, _)| t == "account.added").count(),
2,
"{got:?}"
);
assert_eq!(
got.iter()
.filter(|(t, _)| t == "invoice.payment_received")
.count(),
2
);
assert_eq!(
got.iter().filter(|(t, _)| t == "entry.line_added").count(),
6
);
// One credit per invoice's cash, one for the discount, the fee, the
// discount expense and the net deposit: the rows the event lines name.
let events: Vec<&Value> = got
.iter()
.filter(|(t, _)| t == "invoice.payment_received")
.map(|(_, p)| p)
.collect();
assert_eq!(events.len(), 2);
assert_eq!(events[0]["line"], json!(0));
assert_eq!(events[0]["discount"], json!("10.00"));
assert_eq!(events[0]["discount_line"], json!(2));
assert_eq!(events[0]["fee"], json!("5.00"));
assert_eq!(events[0]["fee_line"], json!(3));
assert_eq!(events[1]["line"], json!(1));
assert!(events[1].get("discount").is_none());
assert!(events[1].get("fee").is_none());
let live = s.ledger().clone();
let ar = live.control_check(d(2026, 4, 10)).unwrap();
let receivables = ar.iter().find(|c| c.role == "receivables").unwrap();
assert_eq!(receivables.balance, cents(0));
assert_eq!(receivables.documents_total, cents(0));
assert_eq!(receivables.difference, Decimal::ZERO);
for (id, paid, discounted) in [(first, 6_000, 1_000), (second, 2_000, 0)] {
let st = live.invoice_state(id, d(2026, 4, 10)).unwrap().unwrap();
assert_eq!(
(st.paid, st.discounted, st.outstanding),
(cents(paid), cents(discounted), Decimal::ZERO)
);
}
assert_eq!(s.ledger().get_entry(entry).unwrap().lines().len(), 6);
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
s.verify().unwrap();
}
/// A settlement of more than one document, a discount or a fee needs
/// `documents_v2`: refused, journaling nothing, in books without it, and
/// accepted after the owner's upgrade, replaying to the same books (plan
/// P4b S6; rules.md B-27, B-42).
#[test]
fn settlement_waits_for_documents_v2() {
use ledger_core::{Application, DocRef, SettleParams};
let dir = fixture("settlement-v2");
let mut s = Store::open(&dir.0).unwrap();
assert!(
!s.journal_format()
.has_feature(ledger_store::format::DOCUMENTS_V2)
);
let bank = s.ledger().chart().find_by_code("1000").unwrap().id;
let head = s.head();
let params = |discount: Decimal| SettleParams {
date: d(2026, 2, 15),
description: "Two bills, one payment".into(),
cash_account: bank,
party: 1,
applications: vec![
Application {
doc: DocRef::Bill(1),
cash: cents(7_000),
discount,
discount_reason: (!discount.is_zero()).then_some("late discount".into()),
},
Application {
doc: DocRef::Bill(2),
cash: cents(4_000),
discount: Decimal::ZERO,
discount_reason: None,
},
],
fee: None,
instrument: None,
reference: None,
};
match s.settle(params(Decimal::ZERO)) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature documents_v2")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(s.head(), head, "nothing journaled");
s.upgrade_format().unwrap();
// The discount is outside the invoice's terms (it has none), so it
// needs its reason; the payment is also accepted only because the
// earlier one was reversed, which needs document_standing too.
s.settle(params(cents(1_000))).unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
let st = s.ledger().bill_state(1, d(2026, 12, 31)).unwrap().unwrap();
assert_eq!(st.status, ledger_core::DocStatus::Paid);
}
/// Legacy payment events carry no `line`, so replay keeps the positional
/// rule: the first untagged control line for the payment's amount, in the
/// order the events come (plan P4b S6; rules.md B-42 INVARIANT 3).
#[test]
fn legacy_payment_events_keep_the_positional_rule() {
use ledger_core::{DocRef, Movement};
let dir = fixture("legacy-positional");
let s = Store::open(&dir.0).unwrap();
let payments: Vec<Value> = std::fs::read_to_string(dir.0.join(JOURNAL_FILE))
.unwrap()
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.filter(|v| v["type"] == json!("invoice.payment_received"))
.map(|v| v["payload"].clone())
.collect();
assert!(!payments.is_empty());
for p in &payments {
assert!(p.get("line").is_none(), "{p}");
}
let lines = s.ledger().doc_lines(DocRef::Invoice(3));
assert_eq!(
lines
.iter()
.map(|l| (l.movement, l.amount))
.collect::<Vec<_>>(),
[
(Movement::Origin, cents(30_000)),
(Movement::Payment, cents(-12_000))
]
);
assert_eq!(
s.ledger()
.line_doc(lines[1].entry, lines[1].line)
.unwrap()
.doc,
DocRef::Invoice(3)
);
}
+69 -1
View File
@@ -1875,6 +1875,8 @@ fn books_written_under_older_rules_still_open() {
(4, "4000", "Revenue", "Revenue"),
(5, "2000", "Accounts Payable", "Liability"),
(6, "6500", "Supplies", "Expense"),
(7, "4100", "Refunds and Allowances", "Revenue"),
(8, "1020", "Bank", "Asset"),
] {
w.append(
"account.added",
@@ -1966,13 +1968,55 @@ fn books_written_under_older_rules_still_open() {
"to": ["billing@fernhill.example"], "kind": "original", "reference": "msg-1"}),
)
.unwrap();
// P4b S6's new shape, hand-written as this version writes it: a
// payment event naming its line and the discount line it also tags.
// This reader tags both; the version before it would drop the discount
// and read the invoice paid above its total.
w.append(
"entry.created",
json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1"}),
)
.unwrap();
for (account, side, amount) in [
(3, "credit", "98.99"),
(3, "credit", "1.00"),
(7, "debit", "1.00"),
(8, "debit", "98.99"),
] {
w.append(
"entry.line_added",
json!({"id": 4, "account": account, "side": side, "amount": amount,
"currency": "USD"}),
)
.unwrap();
}
w.append(
"entry.posted",
json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1",
"reversal_of": null,
"lines": [
{"account": 3, "side": "credit", "amount": "98.99", "currency": "USD"},
{"account": 3, "side": "credit", "amount": "1.00", "currency": "USD"},
{"account": 7, "side": "debit", "amount": "1.00", "currency": "USD"},
{"account": 8, "side": "debit", "amount": "98.99", "currency": "USD"}
]}),
)
.unwrap();
w.append(
"invoice.payment_received",
json!({"invoice_id": 1, "date": "2026-04-05", "amount": "98.99",
"cash_account": 8, "entry_id": 4, "line": 0,
"discount": "1.00", "discount_line": 1,
"discount_reason": "the customer paid the rest late"}),
)
.unwrap();
drop(w);
// Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir).expect("older books still open");
store.verify().unwrap();
assert_eq!(store.journal_format().format, 1);
assert_eq!(store.ledger().chart().len(), 6);
assert_eq!(store.ledger().chart().len(), 8);
assert_eq!(store.ledger().list_periods().len(), 2);
let lines = store.ledger().get_entry(1).unwrap().lines();
assert_eq!(lines.len(), 2);
@@ -2040,6 +2084,30 @@ fn books_written_under_older_rules_still_open() {
assert_eq!(sends[0].to, ["billing@fernhill.example"]);
assert_eq!(sends[0].kind, ledger_core::arap::SendKind::Original);
assert_eq!(sends[0].reference.as_deref(), Some("msg-1"));
// The S6 shape: the invoice reads paid and discounted, and replay
// tagged the lines the event names.
let st = store
.ledger()
.invoice_state(1, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap())
.unwrap()
.unwrap();
assert_eq!(
(st.status, st.paid, st.discounted, st.outstanding),
(
ledger_core::DocStatus::Paid,
"98.99".parse::<Decimal>().unwrap(),
"1.00".parse::<Decimal>().unwrap(),
Decimal::ZERO
)
);
assert_eq!(
store.ledger().line_doc(4, 0).unwrap().movement,
ledger_core::Movement::Payment
);
assert_eq!(
store.ledger().line_doc(4, 1).unwrap().movement,
ledger_core::Movement::Discount
);
}
/// The books an earlier wanderbooks wrote, one directory each under