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

+21
View File
@@ -521,6 +521,27 @@ proptest! {
);
}
/// kernel::settle at i64 (what Kani's settle_is_exact proved) and at
/// Decimal agree: the same cash, discounts and fee give the same
/// applications total and deposit, and both refuse the same cases.
#[test]
fn settle_agrees(
cash in prop::collection::vec(0..=MAX_CENTS, 0..4),
discounts in prop::collection::vec(0..=MAX_CENTS, 0..4),
fee in 0..=MAX_CENTS,
) {
let narrow = kernel::settle(&cash, &discounts, fee);
let wide = kernel::settle(
&cash.iter().map(|&c| dec(c)).collect::<Vec<_>>(),
&discounts.iter().map(|&c| dec(c)).collect::<Vec<_>>(),
dec(fee),
);
prop_assert_eq!(
narrow.map(|s| (dec(s.applications_total), dec(s.deposit))),
wide.map(|s| (s.applications_total, s.deposit))
);
}
/// kernel::line_amount at i64 (what Kani's line_amount_is_exact
/// proved) and at Decimal agree: cents x cents at i64 is the same
/// product as dollars x dollars at Decimal, to four places.
@@ -3662,3 +3662,41 @@ fn a_government_selling_services_is_a_supplier() {
let line = cf.operating.iter().find(|l| l.entry == stamps).unwrap();
assert_eq!(line.class, Some(OperatingClass::OtherPayments));
}
/// A bill whose line is equipment, paid later: the payment's cash is
/// investing, through the bill's split (D-35; rules.md B-14), not
/// operating. Plan P4b S6 keeps this true when the bill is paid as part
/// of a settlement.
#[test]
fn equipment_bill_payment_is_investing_cash() {
use ledger_core::arap::{BillFields, DocumentLine};
let mut b = Books::new();
b.acct("1000", "Bank", AccountType::Asset);
b.acct("1500", "Equipment", AccountType::Asset);
b.acct("2000", "Accounts Payable", AccountType::Liability);
let bank = b.id("1000");
let equipment = b.id("1500");
let vendor = b.l.add_vendor("Tobin Harlow Supply").unwrap();
let bill =
b.l.create_bill_with(
vendor,
d(2026, 1, 10),
d(2026, 2, 9),
vec![DocumentLine {
description: "Laptop".into(),
amount: x("500.00"),
class_id: None,
account: Some(equipment),
quantity: None,
unit_price: None,
}],
BillFields::default(),
)
.unwrap();
b.l.pay_bill(bill, d(2026, 1, 20), x("500.00"), bank)
.unwrap();
let cf = b.cf(d(2026, 1, 1), d(2026, 1, 31), &["1000"], &[], &["1500"]);
assert_eq!(cf.cash_from_investing, -x("500.00"));
assert_eq!(cf.cash_from_operations, Decimal::ZERO);
assert_eq!(cf.net_change_in_cash, -x("500.00"));
}
+377
View File
@@ -1922,3 +1922,380 @@ fn duplicate_vendor_ref_refused_unless_a_reason_is_given() {
)
.unwrap();
}
// ---------------------------------------------------------------------------
// P4b S6: paying on terms (rules.md B-42)
// ---------------------------------------------------------------------------
/// A bill on "2/10 net 30", paid within the discount window: the gross
/// method's entry is Dr A/P the full amount, Cr purchase discounts what
/// was taken, Cr the bank the rest; the bill reads paid and discounted.
/// A discount larger than the terms allow needs a reason (B-42).
#[test]
fn bill_on_terms_with_discount() {
use ledger_core::{Application, DocRef, SettleParams};
let mut b = books();
let terms = Terms::parse("2/10 net 30").unwrap();
let on = d(2026, 4, 1);
let bill =
b.l.create_bill_with(
b.vendor,
on,
terms.due_date(on).unwrap(),
line("Paper", 10_000),
BillFields {
terms: Some(terms),
..BillFields::default()
},
)
.unwrap();
let entry =
b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Bill 1, early".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(bill),
cash: cents(9_800),
discount: cents(200),
discount_reason: None,
}],
fee: None,
instrument: None,
reference: None,
})
.unwrap();
let lines = b.l.get_entry(entry).unwrap().lines();
assert_eq!(lines.len(), 4);
assert_eq!(
(lines[0].account, lines[0].side),
(b.account("2000"), Side::Debit)
);
assert_eq!(lines[0].amount.amount, cents(9_800));
assert_eq!(
(lines[1].account, lines[1].side),
(b.account("2000"), Side::Debit)
);
assert_eq!(lines[1].amount.amount, cents(200));
assert_eq!(
(lines[2].account, lines[2].side),
(b.account("6995"), Side::Credit)
);
assert_eq!(lines[2].amount.amount, cents(200));
assert_eq!((lines[3].account, lines[3].side), (b.bank, Side::Credit));
assert_eq!(lines[3].amount.amount, cents(9_800));
let st = b.state(DocRef::Bill(bill), d(2026, 4, 5));
assert_eq!(
(st.status, st.paid, st.discounted, st.outstanding),
(DocStatus::Paid, cents(9_800), cents(200), Decimal::ZERO)
);
assert_eq!(b.l.line_doc(entry, 0).unwrap().movement, Movement::Payment);
assert_eq!(b.l.line_doc(entry, 1).unwrap().movement, Movement::Discount);
b.ties(d(2026, 4, 5));
// 3% off is more than 2/10 allows: refused without a reason, taken
// with one (a vendor honouring it anyway).
let bill2 =
b.l.create_bill_with(
b.vendor,
on,
Terms::parse("2/10 net 30").unwrap().due_date(on).unwrap(),
line("Paper", 10_000),
BillFields {
terms: Some(Terms::parse("2/10 net 30").unwrap()),
..BillFields::default()
},
)
.unwrap();
let params = |reason: Option<String>| SettleParams {
date: d(2026, 4, 6),
description: "Bill 2, late".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(bill2),
cash: cents(9_700),
discount: cents(300),
discount_reason: reason,
}],
fee: None,
instrument: None,
reference: None,
};
assert!(matches!(
b.l.settle(params(None)),
Err(LedgerError::DiscountNeedsReason { .. })
));
b.l.settle(params(Some("the vendor honoured 3%".into())))
.unwrap();
}
/// A discount taken after the discount date needs a reason (B-42
/// INVARIANT 2); a bill with an asset or prepaid line refuses one with the
/// D-49 remedy, and none of the refusals changes the books.
#[test]
fn late_discount_needs_a_reason() {
use ledger_core::{Application, DocRef, SettleParams};
let mut b = books();
let equipment =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let terms = Terms::parse("2/10 net 30").unwrap();
let on = d(2026, 4, 1);
let bill =
b.l.create_bill_with(
b.vendor,
on,
terms.due_date(on).unwrap(),
line("Paper", 10_000),
BillFields {
terms: Some(terms.clone()),
..BillFields::default()
},
)
.unwrap();
let late = |reason: Option<String>| SettleParams {
date: d(2026, 4, 20),
description: "Bill 1, late discount".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(bill),
cash: cents(9_800),
discount: cents(200),
discount_reason: reason,
}],
fee: None,
instrument: None,
reference: None,
};
assert!(matches!(
b.l.settle(late(None)),
Err(LedgerError::DiscountNeedsReason { .. })
));
b.l.settle(late(Some("the vendor honoured it late".into())))
.unwrap();
// A bill whose line is equipment: no apportioning (D-49).
let asset_bill =
b.l.create_bill_with(
b.vendor,
on,
terms.due_date(on).unwrap(),
vec![DocumentLine {
description: "Laptop".into(),
amount: cents(10_000),
class_id: None,
account: Some(equipment),
quantity: None,
unit_price: None,
}],
BillFields {
terms: Some(terms),
..BillFields::default()
},
)
.unwrap();
let before = b.l.doc_state(DocRef::Bill(asset_bill), d(2026, 4, 5));
let refused = b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Laptop, discounted".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(asset_bill),
cash: cents(9_800),
discount: cents(200),
discount_reason: Some("vendor honoured it".into()),
}],
fee: None,
instrument: None,
reference: None,
});
match refused {
Err(LedgerError::DiscountOnAssetBill { code, .. }) => assert_eq!(code, "1500"),
other => panic!("expected DiscountOnAssetBill, got {other:?}"),
}
assert_eq!(
b.l.doc_state(DocRef::Bill(asset_bill), d(2026, 4, 5)),
before
);
assert_eq!(
b.state(DocRef::Bill(asset_bill), d(2026, 4, 5)).outstanding,
cents(10_000)
);
}
/// The processor's fee posts gross (A-4): Dr the bank the net, Dr the
/// processing-fee expense the fee, Cr A/R the gross; the invoice reads
/// paid and the deposit account has only the net.
#[test]
fn a_fee_posts_gross_to_receivables_and_net_to_the_bank() {
use ledger_core::{Application, DocRef, Fee, SettleParams};
let mut b = books();
let invoice = b.invoice(d(2026, 4, 1), 10_000);
let entry =
b.l.settle(SettleParams {
date: d(2026, 4, 3),
description: "Card payment, fee withheld".into(),
cash_account: b.bank,
party: b.customer,
applications: vec![Application {
doc: DocRef::Invoice(invoice),
cash: cents(10_000),
discount: Decimal::ZERO,
discount_reason: None,
}],
fee: Some(Fee {
amount: cents(300),
account: None,
vendor: None,
}),
instrument: None,
reference: None,
})
.unwrap();
let lines = b.l.get_entry(entry).unwrap().lines();
assert_eq!(lines.len(), 3);
assert_eq!(
(lines[0].account, lines[0].side),
(b.account("1200"), Side::Credit)
);
assert_eq!(lines[0].amount.amount, cents(10_000));
assert_eq!(
(lines[1].account, lines[1].side),
(b.account("6060"), Side::Debit)
);
assert_eq!(lines[1].amount.amount, cents(300));
assert_eq!((lines[2].account, lines[2].side), (b.bank, Side::Debit));
assert_eq!(lines[2].amount.amount, cents(9_700));
assert_eq!(
b.state(DocRef::Invoice(invoice), d(2026, 4, 3)).status,
DocStatus::Paid
);
assert_eq!(b.l.account_balance(b.bank).unwrap(), cents(9_700));
}
/// A payment that settles more than one document names its lines in
/// application order, and two applications on one document are refused:
/// each document's bound is one bound (B-42 INVARIANTS 1 and 2).
#[test]
fn a_settlement_names_its_lines_and_refuses_a_document_twice() {
use ledger_core::{Application, DocRef, SettleParams};
let mut b = books();
let first = b.invoice(d(2026, 4, 1), 10_000);
let second = b.invoice(d(2026, 4, 2), 5_000);
let entry =
b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Two invoices, one deposit".into(),
cash_account: b.bank,
party: b.customer,
applications: vec![
Application {
doc: DocRef::Invoice(first),
cash: cents(10_000),
discount: Decimal::ZERO,
discount_reason: None,
},
Application {
doc: DocRef::Invoice(second),
cash: cents(5_000),
discount: Decimal::ZERO,
discount_reason: None,
},
],
fee: None,
instrument: None,
reference: None,
})
.unwrap();
let lines = b.l.get_entry(entry).unwrap().lines();
assert_eq!(lines.len(), 3);
assert_eq!(b.l.line_doc(entry, 0).unwrap().doc, DocRef::Invoice(first));
assert_eq!(b.l.line_doc(entry, 1).unwrap().doc, DocRef::Invoice(second));
assert!(b.l.line_doc(entry, 2).is_none());
assert_eq!(b.l.account_balance(b.bank).unwrap(), cents(15_000));
let thrice = b.invoice(d(2026, 4, 3), 10_000);
let before = b.l.account_balance(b.bank).unwrap();
let refused = b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Twice".into(),
cash_account: b.bank,
party: b.customer,
applications: vec![
Application {
doc: DocRef::Invoice(thrice),
cash: cents(5_000),
discount: Decimal::ZERO,
discount_reason: None,
},
Application {
doc: DocRef::Invoice(thrice),
cash: cents(5_000),
discount: Decimal::ZERO,
discount_reason: None,
},
],
fee: None,
instrument: None,
reference: None,
});
assert!(matches!(
refused,
Err(LedgerError::SettlementDuplicateDocument(_))
));
assert_eq!(b.l.account_balance(b.bank).unwrap(), before);
}
/// A discount on a bill with an asset line is refused, never apportioned
/// (D-49): the refusal names the asset account and both remedies (record
/// the bill net, or reclassify Dr 6995 / Cr the asset).
#[test]
fn discount_on_an_asset_bill_is_refused_with_the_remedy() {
use ledger_core::{Application, DocRef, SettleParams};
let mut b = books();
let equipment =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let bill =
b.l.create_bill(
b.vendor,
d(2026, 4, 1),
d(2026, 5, 1),
vec![DocumentLine {
description: "Laptop".into(),
amount: cents(10_000),
class_id: None,
account: Some(equipment),
quantity: None,
unit_price: None,
}],
)
.unwrap();
match b.l.settle(SettleParams {
date: d(2026, 4, 5),
description: "Laptop, discounted".into(),
cash_account: b.bank,
party: b.vendor,
applications: vec![Application {
doc: DocRef::Bill(bill),
cash: cents(9_800),
discount: cents(200),
discount_reason: Some("the vendor honoured it".into()),
}],
fee: None,
instrument: None,
reference: None,
}) {
Err(LedgerError::DiscountOnAssetBill { code, .. }) => assert_eq!(code, "1500"),
other => panic!("expected DiscountOnAssetBill, got {other:?}"),
}
assert_eq!(
b.state(DocRef::Bill(bill), d(2026, 4, 5)).outstanding,
cents(10_000)
);
b.ties(d(2026, 4, 5));
}