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:
1 parent
ed637f0e27
commit
10ee0681c1
15 files changed
+1511
-146
No files matched your search
+112
-13
@@ -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::*;
|
||||
|
||||
Reference in new issue
Block a user