feat(arap): settle documents with discounts and a fee (P4b S6)

One payment had to name one document and could take no discount or
processor fee, so paying several invoices from one deposit meant several
entries and the fee had nowhere to go. This adds the kernel's exact
settlement arithmetic (applications total, net deposit), a Ledger::settle
that posts one entry for a customer's or vendor's documents, and the gross
method and gross fee A-8/A-4 chose.

- kernel::settle: sum(cash)+sum(discounts)=applications, sum(cash)-fee=
  deposit, None on overflow or a negative deposit. Kani settle_is_exact
  plus the Decimal bridge.
- Ledger::settle / settlement_lines: one party and one kind per
  settlement, one application per document, each cash+discount within
  payment_room; a discount within the terms (discount_max and
  discount_by) needs no reason, a larger or later one does; a bill with
  an asset line is refused with D-49's remedy; a fee is a customer
  payment's only and posts gross.
- Rows are payment lines in application order, then discount lines, the
  fee line and the cash line, so a journaled event's line indices are
  stable; replay tags exactly the line an event names and leaves an
  unmatched line unattached (never refuses).
- receive_payment_with and pay_bill_with become one-application calls.
- Roles: sales_allowances (4100), purchase_discounts (6995) and
  payment_processing_fees (6060), constants used on demand but not added
  to control_accounts() (existing charts may use those codes for other
  things).
- Lean: settle posts through Step.document, so settle_posts_a_document_step
  and settle_unattached, no new constructor; both in Audit.lean.
- Core tests: bill_on_terms_with_discount, late_discount_needs_a_reason,
  discount_on_an_asset_bill_is_refused_with_the_remedy,
  a_fee_posts_gross_to_receivables_and_net_to_the_bank,
  a_settlement_names_its_lines_and_refuses_a_document_twice, and
  equipment_bill_payment_is_investing_cash in the cash-flow suite.
- Bookspec B-42 is stated, with its id BS-arap-6; the catalogue and index
  now record it as CONVENTION.

just check and just prove green.
This commit is contained in:
swaits committed 2026-10-27 12:59:05 -06:00
1 parent ed637f0e27
commit 10ee0681c1
15 files changed
+1511 -146

No files matched your search

+72 -1
View File
@@ -676,9 +676,80 @@ pub enum LedgerError {
InvalidPaymentAmount(Decimal),
/// Payment exceeds the remaining balance.
#[error("payment of {0} exceeds remaining balance of {1}")]
#[error(
"payment of {0} exceeds remaining balance of {1}; the difference is the excess: record it as a plain entry to a customer-deposits liability (2300) and apply it later"
)]
Overpayment(Decimal, Decimal),
/// A settlement named no documents.
#[error("a settlement needs at least one invoice or bill")]
SettlementEmpty,
/// A settlement's documents aren't all invoices or all bills.
#[error("a settlement cannot mix invoices and bills: {0} is not the same kind as the others")]
SettlementMixedKinds(String),
/// A settlement's documents don't all belong to the one party it
/// names (rules.md B-42 INVARIANT 1).
#[error("{0} is not {1}'s: a settlement covers one party only")]
SettlementPartiesDiffer(String, u64),
/// A discount needs a reason: none is stated in the document's terms,
/// or it is larger than its terms allow, or it is taken after the
/// discount date (rules.md B-42 INVARIANT 2).
#[error(
"{doc}: the discount of {discount} needs a reason ({because}); pass discount_reason with the payment"
)]
DiscountNeedsReason {
/// The document.
doc: String,
/// The discount taken.
discount: Decimal,
/// Why the terms don't allow it as a matter of course.
because: String,
},
/// A discount on a bill whose lines include an asset or prepaid
/// account is refused: apportioning it across those lines is deferred
/// (D-49).
#[error(
"{doc} has a line on {code} \"{name}\" (an asset or prepaid account), so a discount on it cannot be apportioned (D-49): record the bill net of the discount, or reclassify by an entry Dr 6995 Purchase Discounts / Cr {code}"
)]
DiscountOnAssetBill {
/// The bill.
doc: String,
/// The asset line's account code.
code: String,
/// Its name.
name: String,
},
/// A settlement names the same document twice: each document takes one
/// application (its cash and its discount), so its bound is one bound.
#[error("{0} appears twice in one settlement: name it once, with all its cash and discount")]
SettlementDuplicateDocument(String),
/// A settlement's discount reason is not one line of at most
/// [`crate::arap::MAX_REFERENCE_CHARS`] characters.
#[error("a discount's reason is one line of at most {0} characters")]
InvalidDiscountReason(usize),
/// A processor's fee is a customer payment's only: a bill is paid from
/// the company's own money, with no processor taking a cut.
#[error("a processor's fee belongs on a customer payment, not on paying a bill")]
FeeOnBill,
/// A fee's amount or account the books can't take: the message says
/// why.
#[error("{0}")]
InvalidFee(String),
/// A settlement's figures can't be exact: the cash and discounts
/// don't cover the applications, or the fee leaves a negative deposit
/// (rules.md B-42 INVARIANT 1).
#[error("{0}")]
InvalidSettlement(String),
/// A payment's details were refused (its reference): the message says
/// why.
#[error("{0}")]