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
@@ -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"));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user