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

+112 -13
View File
@@ -425,6 +425,64 @@ impl PaymentDetails {
}
}
/// One document a settlement applies to (plan P4b S6; rules.md B-42): the
/// cash paid on it, the early-payment discount taken off it, and why a
/// discount outside its terms was taken anyway.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Application {
/// The invoice or bill it applies to.
pub doc: DocRef,
/// The cash paid against it, positive.
pub cash: Decimal,
/// The early-payment discount taken, zero when none.
pub discount: Decimal,
/// Why a discount outside the document's terms was taken (a vendor who
/// honours a late discount): one line, journaled with the payment.
pub discount_reason: Option<String>,
}
/// A processor's fee withheld from a customer payment (plan P4b S6; A-4):
/// receivables are credited the gross, the fee goes to its expense
/// account, and the deposit account gets the net.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Fee {
/// The fee, positive.
pub amount: Decimal,
/// The expense account it goes to; `None` is the
/// `payment_processing_fees` role's account.
pub account: Option<crate::AccountId>,
/// The processor, when the books know it as a vendor.
pub vendor: Option<VendorId>,
}
/// Everything one settlement needs (plan P4b S6; rules.md B-42): its date,
/// description, deposit account, the party every document belongs to, the
/// applications, and the processor's fee when one was withheld.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SettleParams {
/// The settlement's date.
pub date: NaiveDate,
/// The entry's description.
pub description: String,
/// The account the cash reaches (or leaves): a bank, a card, a payable
/// holding no role.
pub cash_account: crate::AccountId,
/// The customer or vendor every application names.
pub party: u64,
/// One or more documents, one party, one kind.
pub applications: Vec<Application>,
/// The processor's fee, on a customer payment only.
pub fee: Option<Fee>,
/// The payment method, when known (D-40).
pub instrument: Option<crate::InstrumentId>,
/// The processor's or bank's reference, when there is one.
pub reference: Option<String>,
}
/// One posted line a settlement's plan asks for, with the document it tags
/// when it is a control line ([`Application`]'s payment or discount).
pub type SettlementRow = (crate::AccountId, crate::Side, Decimal, Option<DocTag>);
/// How a document's payment terms count their days (rules.md B-39; plan
/// P4b S4.1): from the document's date, from the end of its month, or a
/// date the parties agreed.
@@ -1167,6 +1225,35 @@ impl AgingRow {
}
}
/// One document in an aging's detail: its figures and the bucket it falls
/// in (plan P4b S6, 8.3.10). Every figure is derived from its tagged lines
/// ([`crate::Ledger::doc_state`]).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AgingDocument {
/// The invoice or bill.
pub doc: DocRef,
/// The customer or vendor.
pub party: String,
/// Its own date.
pub date: NaiveDate,
/// When payment is due.
pub due_date: NaiveDate,
/// Its terms, as they print ("2/10 net 30"), when it has them.
pub terms: Option<String>,
/// The sum of its lines.
pub total: Decimal,
/// Cash paid that stands.
pub paid: Decimal,
/// Discounts taken that stand.
pub discounted: Decimal,
/// What it still owes as of the day.
pub outstanding: Decimal,
/// `current`, `days_30`, `days_60`, `days_90` or `days_90_plus`.
pub bucket: &'static str,
/// Days past its due date while something is owed; 0 otherwise.
pub days_overdue: i64,
}
/// Put an open balance in its aging bucket.
///
/// ```text
@@ -1199,23 +1286,35 @@ pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) ->
days_90: Decimal::ZERO,
days_90_plus: Decimal::ZERO,
};
if due_date >= as_of {
row.current = balance;
} else {
let days = (as_of - due_date).num_days();
if days <= 30 {
row.days_30 = balance;
} else if days <= 60 {
row.days_60 = balance;
} else if days <= 90 {
row.days_90 = balance;
} else {
row.days_90_plus = balance;
}
match bucket_key(due_date, as_of) {
"current" => row.current = balance,
"days_30" => row.days_30 = balance,
"days_60" => row.days_60 = balance,
"days_90" => row.days_90 = balance,
_ => row.days_90_plus = balance,
}
row
}
/// The aging bucket a document due `due_date` falls in as of `as_of`, by
/// the edges [`aging_bucket`] uses: `current` (due today is current),
/// `days_30`, `days_60`, `days_90` or `days_90_plus`.
pub fn bucket_key(due_date: NaiveDate, as_of: NaiveDate) -> &'static str {
if due_date >= as_of {
return "current";
}
let days = (as_of - due_date).num_days();
if days <= 30 {
"days_30"
} else if days <= 60 {
"days_60"
} else if days <= 90 {
"days_90"
} else {
"days_90_plus"
}
}
#[cfg(test)]
mod tests {
use super::*;