diff --git a/bookspec/checker/src/rules.rs b/bookspec/checker/src/rules.rs index dc59220..34f0c90 100644 --- a/bookspec/checker/src/rules.rs +++ b/bookspec/checker/src/rules.rs @@ -555,8 +555,8 @@ const SPEC: Spec = &[ ( 42, Area::Arap, - "Settlements and the fee (allocated, not yet stated)", - None, + "A settlement is exact, one party, and names its lines; the gross discount and the gross fee", + Some(Status::Convention), &[], ), ( diff --git a/bookspec/spec/rules-index.md b/bookspec/spec/rules-index.md index 436f328..90c1b84 100644 --- a/bookspec/spec/rules-index.md +++ b/bookspec/spec/rules-index.md @@ -7,9 +7,9 @@ what its statement rests on and the examples that demonstrate it. A rule whose only source is a human choice carries rules.md's **CHOICE**, bookspec's status of the same name: the specification names the options, an engine declares its pick (`claims.choices`, `bookspec_checker::Claims`). Rules B-39 to B-45 are -allocated in `plan/served.md` 8.0.3 and not yet stated: their -identifier and area are fixed here so a later unit fills them in -without moving anyone else's number. +allocated in `plan/served.md` 8.0.3; those not yet stated keep only +their identifier and area here, so a later unit fills them in without +moving anyone else's number. | B | bookspec id | area | status | rule | examples | |---|---|---|---|---|---| @@ -54,7 +54,7 @@ without moving anyone else's number. | B-39 | `BS-arap-3` | arap | — | Terms: when a document is due (allocated, not yet stated) | — | | B-40 | `BS-arap-4` | arap | — | Numbers and the issued copy (allocated, not yet stated) | — | | B-41 | `BS-arap-5` | arap | — | Write-offs (allocated, not yet stated) | — | -| B-42 | `BS-arap-6` | arap | — | Settlements and the fee (allocated, not yet stated) | — | +| B-42 | `BS-arap-6` | arap | CONVENTION | A settlement is exact, one party, and names its lines; the gross discount and the gross fee | — | | B-43 | `BS-entry-5` | entry | — | Drafts and evidence (allocated, not yet stated) | — | | B-44 | `BS-ledger-12` | ledger | — | Extraction and dedup (allocated, not yet stated) | — | | B-45 | `BS-ledger-13` | ledger | — | Audits and attestations (allocated, not yet stated) | — | diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index cd8ebd5..9e1fe75 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -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, +} + +/// 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, + /// The processor, when the books know it as a vendor. + pub vendor: Option, +} + +/// 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, + /// The processor's fee, on a customer payment only. + pub fee: Option, + /// The payment method, when known (D-40). + pub instrument: Option, + /// The processor's or bank's reference, when there is one. + pub reference: Option, +} + +/// 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); + /// 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, + /// 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::*; diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 28212b3..8504a5d 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -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}")] diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 9881b12..5a99d08 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -269,6 +269,63 @@ pub fn discount_max(total: T, percent_bp: u32) -> Option { total.checked_mul(bp)?.checked_div_exact(ten_thousand) } +/// What a settlement of one or more documents comes to: the applications' +/// total (cash plus discounts taken) and what reaches the deposit account +/// (the cash less the processor's fee). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Settlement { + /// Σ cash + Σ discounts: what the settlement applies to the documents. + pub applications_total: T, + /// Σ cash − fee: what the deposit account receives (never negative). + pub deposit: T, +} + +/// The arithmetic of a settlement (rules.md B-42; plan P4b S6): each +/// document's cash and discount, and the processor's fee withheld from the +/// cash that reaches the bank. +/// +/// ```text +/// GOAL: a settlement is exact: what the documents are settled by is the +/// cash plus the discounts, and the deposit is the cash less the fee. +/// SOURCE: docs/spec/rules.md B-42; plan/served.md 8.3.6, 8.3.13 (B-42); +/// CONVENTION; DECISION: the gross method (A-8) and the fee's +/// gross presentation (A-4). +/// INVARIANTS: +/// 1. Some(s): s.applications_total = Σ cash + Σ discounts and +/// s.deposit = Σ cash − fee, exactly, with s.deposit >= 0. +/// 2. None on overflow, a negative amount, a negative fee, a fee above +/// the cash, or arrays of different lengths: never a wrapped or +/// rounded figure. +/// ``` +pub fn settle(cash: &[T], discounts: &[T], fee: T) -> Option> { + if cash.len() != discounts.len() || fee < T::ZERO { + return None; + } + let mut total_cash = T::ZERO; + for &amount in cash { + if amount < T::ZERO { + return None; + } + total_cash = total_cash.checked_add(amount)?; + } + let mut total_discounts = T::ZERO; + for &amount in discounts { + if amount < T::ZERO { + return None; + } + total_discounts = total_discounts.checked_add(amount)?; + } + let applications_total = total_cash.checked_add(total_discounts)?; + let deposit = total_cash.checked_sub(fee)?; + if deposit < T::ZERO { + return None; + } + Some(Settlement { + applications_total, + deposit, + }) +} + /// A document line's amount from its quantity and unit price: the exact /// product, never rounded. /// diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index a24a24c..855a5cd 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -91,6 +91,34 @@ pub const GENERAL_EXPENSES: ControlAccount = control( "bill expense", AccountType::Expense, ); +/// A control account: discounts given to customers (plan P4b S6, A-8; plan +/// 8.3.4): a debit-balance revenue account, the gross method's allowance +/// when a customer takes an early-payment discount. +pub const SALES_ALLOWANCES: ControlAccount = control( + "sales_allowances", + "4100", + "Refunds and Allowances", + "sales allowances", + AccountType::Revenue, +); +/// A control account: discounts taken from vendors (plan P4b S6, A-8): a +/// credit-balance expense account, the gross method's purchase discount. +pub const PURCHASE_DISCOUNTS: ControlAccount = control( + "purchase_discounts", + "6995", + "Purchase Discounts", + "purchase discounts", + AccountType::Expense, +); +/// A control account: the processor's fee on a payment received (plan P4b +/// S6, A-4): the gross presentation's fee expense. +pub const PAYMENT_PROCESSING_FEES: ControlAccount = control( + "payment_processing_fees", + "6060", + "Platform and Payment Processing Fees", + "payment processing fees", + AccountType::Expense, +); /// A control account; see [`ControlAccount`]. pub const RETAINED_EARNINGS: ControlAccount = control( "retained_earnings", @@ -6094,6 +6122,415 @@ impl Ledger { } } + /// Settle one or more invoices or bills in one entry (plan P4b S6; + /// rules.md B-42): cash against each, an early-payment discount where + /// the document's terms allow it (or a reason says why), and the + /// processor's fee on a customer payment, posted gross. + /// + /// ```text + /// GOAL: record a payment that settles several documents at once, with + /// the early-payment discounts and the processor's fee, exactly. + /// SOURCE: docs/spec/rules.md B-42; plan/served.md 8.3.6, 8.3.13; + /// CONVENTION; DECISION: the gross method (A-8) and the fee's + /// gross presentation (A-4). + /// INVARIANTS: + /// 1. Σ cash + Σ discounts = Σ applications and deposit = Σ cash − + /// fee (kernel::settle); one party per settlement. + /// 2. Each application's cash + discount ≤ what the document owes on + /// every day from the payment's date on. + /// 3. Posting rows: payment lines in application order, then the + /// discount lines, then the fee line, then the cash line, so a + /// journaled event's line indices are stable. + /// 4. A discount is at most kernel::discount_max(total, percent_bp) + /// and dated on or before terms.discount_by(date), else a reason + /// is required; a discount on a bill with an asset line is + /// refused (D-49); a fee is refused on a bill. + /// 5. A refusal MUST leave the books unchanged. + /// ``` + pub fn settle(&mut self, params: arap::SettleParams) -> Result { + self.atomically(|l| l.settle_steps(params)) + } + + /// The lines [`Self::settle`] would post, after every check, in its + /// order (payment lines, discount lines, the fee line, the cash line). + /// A store journals exactly these before it mutates; it must have + /// created the role accounts [`Self::settle`] would create first. + pub fn settlement_lines( + &self, + params: &arap::SettleParams, + ) -> Result, LedgerError> { + use arap::{DocRef, DocTag, Movement}; + + if params.applications.is_empty() { + return Err(LedgerError::SettlementEmpty); + } + let kind = match params.applications[0].doc { + DocRef::Invoice(_) => arap::DocumentKind::Invoice, + DocRef::Bill(_) => arap::DocumentKind::Bill, + }; + let control = match kind { + arap::DocumentKind::Invoice => RECEIVABLES, + arap::DocumentKind::Bill => PAYABLES, + }; + let discount_role = match kind { + arap::DocumentKind::Invoice => SALES_ALLOWANCES, + arap::DocumentKind::Bill => PURCHASE_DISCOUNTS, + }; + for app in ¶ms.applications { + let same = matches!( + (kind, app.doc), + (arap::DocumentKind::Invoice, DocRef::Invoice(_)) + | (arap::DocumentKind::Bill, DocRef::Bill(_)) + ); + if !same { + return Err(LedgerError::SettlementMixedKinds(app.doc.to_string())); + } + let party = match app.doc { + DocRef::Invoice(id) => { + self.invoices + .get(&id) + .ok_or(LedgerError::UnknownInvoice(id))? + .customer_id + } + DocRef::Bill(id) => { + self.bills + .get(&id) + .ok_or(LedgerError::UnknownBill(id))? + .vendor_id + } + }; + if party != params.party { + return Err(LedgerError::SettlementPartiesDiffer( + app.doc.to_string(), + params.party, + )); + } + } + crate::calendar::check(params.date)?; + if let Some(pid) = self.closed_period_containing(params.date) { + return Err(LedgerError::ClosedPeriod(pid, params.date.to_string())); + } + let details = arap::PaymentDetails { + instrument: params.instrument, + reference: params.reference.clone(), + }; + details.check()?; + if let Some(i) = params.instrument + && !self.instruments.contains_key(&i) + { + return Err(LedgerError::UnknownInstrument(i)); + } + self.check_controls(&[control])?; + self.check_payment_account(params.cash_account)?; + let control_account = self.role_account(&control).ok_or_else(|| { + LedgerError::InvalidSettlement(format!( + "{} has no account in these books yet", + control.role + )) + })?; + + let mut cash = Vec::with_capacity(params.applications.len()); + let mut discounts = Vec::with_capacity(params.applications.len()); + for (i, app) in params.applications.iter().enumerate() { + if params.applications[..i] + .iter() + .any(|earlier| earlier.doc == app.doc) + { + return Err(LedgerError::SettlementDuplicateDocument( + app.doc.to_string(), + )); + } + if app.cash <= Decimal::ZERO { + return Err(LedgerError::InvalidPaymentAmount(app.cash)); + } + if app.discount < Decimal::ZERO { + return Err(LedgerError::InvalidSettlement(format!( + "{}: a discount cannot be negative", + app.doc + ))); + } + let (doc_date, _, _, total) = self.doc_header(app.doc)?; + if params.date < doc_date { + return Err(LedgerError::PaymentBeforeDocument { + date: params.date, + document_date: doc_date, + }); + } + let room = self.payment_room(app.doc, params.date)?; + let applied = app.cash.checked_add(app.discount).ok_or_else(|| { + LedgerError::TooLargeToCompute(format!("{}'s settlement", app.doc)) + })?; + if applied > room { + return Err(LedgerError::Overpayment(applied, room)); + } + if let Some(reason) = &app.discount_reason + && (reason.contains(['\n', '\r']) + || reason.chars().count() > arap::MAX_REFERENCE_CHARS) + { + return Err(LedgerError::InvalidDiscountReason( + arap::MAX_REFERENCE_CHARS, + )); + } + if app.discount > Decimal::ZERO { + self.check_discount(app, kind, total, doc_date, params.date)?; + } + cash.push(app.cash); + discounts.push(app.discount); + } + let fee_amount = params.fee.as_ref().map_or(Decimal::ZERO, |f| f.amount); + let settlement = kernel::settle(&cash, &discounts, fee_amount).ok_or_else(|| { + LedgerError::InvalidSettlement( + "the cash and discounts don't cover the applications exactly, or the fee is above the cash" + .into(), + ) + })?; + if settlement.deposit <= Decimal::ZERO { + return Err(LedgerError::InvalidSettlement( + "the processor's fee can't take the whole payment: the deposit would be nothing" + .into(), + )); + } + let any_discount = discounts.iter().any(|d| *d > Decimal::ZERO); + let mut total_discount = Decimal::ZERO; + for app in ¶ms.applications { + if app.discount > Decimal::ZERO { + total_discount = total_discount + .checked_add(app.discount) + .ok_or_else(|| LedgerError::TooLargeToCompute("the discounts".into()))?; + } + } + let discount_account = if any_discount { + Some(self.role_account(&discount_role).ok_or_else(|| { + LedgerError::InvalidSettlement(format!( + "{} has no account in these books yet", + discount_role.role + )) + })?) + } else { + None + }; + let fee_account = match ¶ms.fee { + None => None, + Some(fee) => Some(match fee.account { + Some(a) => { + self.check_line_account(a)?; + let account = self.chart.get(a).expect("check_line_account passed"); + if account.account_type != AccountType::Expense || account.placeholder { + return Err(LedgerError::InvalidFee(format!( + "{} {} is not an expense account: the fee goes to an expense", + account.code, account.name + ))); + } + a + } + None => { + self.check_controls(&[PAYMENT_PROCESSING_FEES])?; + self.role_account(&PAYMENT_PROCESSING_FEES).ok_or_else(|| { + LedgerError::InvalidSettlement( + "payment_processing_fees has no account in these books yet".into(), + ) + })? + } + }), + }; + + let (control_side, discount_side, cash_side) = match kind { + arap::DocumentKind::Invoice => (Side::Credit, Side::Debit, Side::Debit), + arap::DocumentKind::Bill => (Side::Debit, Side::Credit, Side::Credit), + }; + let mut rows: Vec = + Vec::with_capacity(2 * params.applications.len() + 3); + for (app, cash) in params.applications.iter().zip(cash.iter()) { + rows.push(( + control_account, + control_side, + *cash, + Some(DocTag { + doc: app.doc, + movement: Movement::Payment, + }), + )); + } + for app in ¶ms.applications { + if app.discount > Decimal::ZERO { + rows.push(( + control_account, + control_side, + app.discount, + Some(DocTag { + doc: app.doc, + movement: Movement::Discount, + }), + )); + } + } + if let Some(fee) = ¶ms.fee { + rows.push(( + fee_account.expect("a fee has its account"), + Side::Debit, + fee.amount, + None, + )); + } + if let Some(account) = discount_account { + rows.push((account, discount_side, total_discount, None)); + } + rows.push((params.cash_account, cash_side, settlement.deposit, None)); + Ok(rows) + } + + /// Refuse a discount the document's terms don't allow without a reason + /// ([`Self::settlement_lines`] SCENARIO 4), and a discount on a bill + /// with an asset or prepaid line (D-49). + fn check_discount( + &self, + app: &arap::Application, + kind: arap::DocumentKind, + total: Decimal, + doc_date: NaiveDate, + date: NaiveDate, + ) -> Result<(), LedgerError> { + if kind == arap::DocumentKind::Bill + && let arap::DocRef::Bill(id) = app.doc + && let Some(bill) = self.bills.get(&id) + { + for line in &bill.lines { + let account = match line.account { + Some(a) => a, + None => continue, + }; + if self + .chart + .get(account) + .is_some_and(|a| a.account_type == AccountType::Asset) + { + let a = self.chart.get(account).expect("checked above"); + return Err(LedgerError::DiscountOnAssetBill { + doc: app.doc.to_string(), + code: a.code.clone(), + name: a.name.clone(), + }); + } + } + } + let terms = match app.doc { + arap::DocRef::Invoice(id) => self.invoices.get(&id).and_then(|i| i.terms.clone()), + arap::DocRef::Bill(id) => self.bills.get(&id).and_then(|b| b.terms.clone()), + }; + let (max, by) = match terms { + Some(t) => { + let max = t + .discount() + .and_then(|d| kernel::discount_max(total, d.percent_bp)); + (max, t.discount_by(doc_date)) + } + None => (None, None), + }; + let within = max.is_some_and(|m| app.discount <= m) && by.is_some_and(|b| date <= b); + let has_reason = app + .discount_reason + .as_deref() + .is_some_and(|r| !r.trim().is_empty()); + if !within && !has_reason { + let because = match (max, by) { + (None, _) => { + "its terms state no early-payment discount this version can bound".to_string() + } + (Some(m), _) if app.discount > m => { + format!("it is more than the terms' {m}") + } + (Some(_), None) => "its terms state no discount date".to_string(), + (Some(_), Some(b)) => format!("it is after the discount date {b}"), + }; + return Err(LedgerError::DiscountNeedsReason { + doc: app.doc.to_string(), + discount: app.discount, + because, + }); + } + Ok(()) + } + + /// Create the accounts a settlement posts to: the control role, the + /// discount role when a discount is taken, and the fee's default role. + fn ensure_settlement_accounts( + &mut self, + params: &arap::SettleParams, + ) -> Result<(), LedgerError> { + let kind = match params.applications.first().map(|a| a.doc) { + Some(arap::DocRef::Bill(_)) => arap::DocumentKind::Bill, + _ => arap::DocumentKind::Invoice, + }; + let control = match kind { + arap::DocumentKind::Invoice => RECEIVABLES, + arap::DocumentKind::Bill => PAYABLES, + }; + self.check_controls(&[control])?; + self.ensure_control(&control); + if params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + { + let discount_role = match kind { + arap::DocumentKind::Invoice => SALES_ALLOWANCES, + arap::DocumentKind::Bill => PURCHASE_DISCOUNTS, + }; + self.check_controls(&[discount_role])?; + self.ensure_control(&discount_role); + } + if let Some(fee) = ¶ms.fee + && fee.account.is_none() + { + self.check_controls(&[PAYMENT_PROCESSING_FEES])?; + self.ensure_control(&PAYMENT_PROCESSING_FEES); + } + Ok(()) + } + + /// [`Self::settle`]'s steps, run inside [`Self::atomically`]. + fn settle_steps(&mut self, params: arap::SettleParams) -> Result { + self.ensure_settlement_accounts(¶ms)?; + let rows = self.settlement_lines(¶ms)?; + let description = if params.description.trim().is_empty() { + format!( + "Settlement of {} document{}", + params.applications.len(), + if params.applications.len() == 1 { + "" + } else { + "s" + } + ) + } else { + params.description.clone() + }; + let entry = self.create_entry(params.date, &description); + for (account, side, amount, tag) in &rows { + let money = Money::new(*amount, self.base_currency); + match tag { + Some(tag) => self.add_tagged_line(entry, *account, *side, money, *tag)?, + None => self.add_line(entry, *account, *side, money)?, + } + } + self.post_entry(entry)?; + match params.applications[0].doc { + arap::DocRef::Invoice(_) => self.tag_customer(entry, params.party), + arap::DocRef::Bill(_) => self.tag_vendor(entry, params.party), + } + if params.instrument.is_some() { + self.set_entry_instrument(entry, params.instrument)?; + } + for app in ¶ms.applications { + self.payment_events + .entry(app.doc) + .or_default() + .push(app.cash); + self.refresh_doc(app.doc); + } + Ok(entry) + } + /// Record a customer payment against an invoice. Posts: Dr Cash, /// Cr A/R ([`Self::receive_payment_with`], with no payment method or /// reference). @@ -6147,14 +6584,25 @@ impl Ledger { cash_account: AccountId, details: &arap::PaymentDetails, ) -> Result { - self.atomically(|l| { - l.payment_steps( - arap::DocRef::Invoice(invoice_id), - date, - amount, - cash_account, - details, - ) + let customer = self + .invoices + .get(&invoice_id) + .ok_or(LedgerError::UnknownInvoice(invoice_id))? + .customer_id; + self.settle(arap::SettleParams { + date, + description: details.description(&format!("Payment on invoice {invoice_id}")), + cash_account, + party: customer, + applications: vec![arap::Application { + doc: arap::DocRef::Invoice(invoice_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), }) } @@ -6206,100 +6654,28 @@ impl Ledger { cash_account: AccountId, details: &arap::PaymentDetails, ) -> Result { - self.atomically(|l| { - l.payment_steps( - arap::DocRef::Bill(bill_id), - date, - amount, - cash_account, - details, - ) + let vendor = self + .bills + .get(&bill_id) + .ok_or(LedgerError::UnknownBill(bill_id))? + .vendor_id; + self.settle(arap::SettleParams { + date, + description: details.description(&format!("Payment on bill {bill_id}")), + cash_account, + party: vendor, + applications: vec![arap::Application { + doc: arap::DocRef::Bill(bill_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), }) } - /// The steps of a payment ([`Self::receive_payment_with`], - /// [`Self::pay_bill_with`]), run inside [`Self::atomically`]. - fn payment_steps( - &mut self, - doc: arap::DocRef, - date: NaiveDate, - amount: Decimal, - account: AccountId, - details: &arap::PaymentDetails, - ) -> Result { - if amount <= Decimal::ZERO { - return Err(LedgerError::InvalidPaymentAmount(amount)); - } - let (doc_date, ..) = self.doc_header(doc)?; - let room = self.payment_room(doc, date)?; - if amount > room { - return Err(LedgerError::Overpayment(amount, room)); - } - if let Some(pid) = self.closed_period_containing(date) { - return Err(LedgerError::ClosedPeriod(pid, date.to_string())); - } - if date < doc_date { - return Err(LedgerError::PaymentBeforeDocument { - date, - document_date: doc_date, - }); - } - details.check()?; - if let Some(i) = details.instrument - && !self.instruments.contains_key(&i) - { - return Err(LedgerError::UnknownInstrument(i)); - } - let role = match doc { - arap::DocRef::Invoice(_) => RECEIVABLES, - arap::DocRef::Bill(_) => PAYABLES, - }; - self.check_controls(&[role])?; - // Every check before the entry exists: a refusal leaves no draft. - self.check_payment_account(account)?; - let control = self.ensure_control(&role); - let money = Money::new(amount, self.base_currency); - let tag = arap::DocTag { - doc, - movement: arap::Movement::Payment, - }; - let entry = match doc { - arap::DocRef::Invoice(id) => { - let entry = self.create_entry( - date, - &details.description(&format!("Payment on invoice {id}")), - ); - self.add_line(entry, account, Side::Debit, money)?; - self.add_tagged_line(entry, control, Side::Credit, money, tag)?; - entry - } - arap::DocRef::Bill(id) => { - let entry = - self.create_entry(date, &details.description(&format!("Payment on bill {id}"))); - self.add_tagged_line(entry, control, Side::Debit, money, tag)?; - self.add_line(entry, account, Side::Credit, money)?; - entry - } - }; - self.post_entry(entry)?; - match doc { - arap::DocRef::Invoice(id) => { - let customer = self.invoices[&id].customer_id; - self.tag_customer(entry, customer); - } - arap::DocRef::Bill(id) => { - let vendor = self.bills[&id].vendor_id; - self.tag_vendor(entry, vendor); - } - } - if details.instrument.is_some() { - self.set_entry_instrument(entry, details.instrument)?; - } - self.payment_events.entry(doc).or_default().push(amount); - self.refresh_doc(doc); - Ok(entry) - } - /// Void a document: reverse its own entry (B-9), described "Void /// invoice 7: reason". It keeps its id (and, later, its number); its /// status as of the void's date on is Void. Plain @@ -6612,6 +6988,35 @@ impl Ledger { } } + /// Tag the line a new payment event names (plan P4b S6; rules.md B-42 + /// INVARIANT 3): line `index` of `entry`, when it is on the role's + /// account, on the side the movement posts, untagged, and for `amount`. + /// Otherwise it stays untagged and the control check lists it; replay + /// never refuses (B-28). + fn tag_replayed_at( + &mut self, + entry: EntryId, + role: &ControlAccount, + index: usize, + amount: Decimal, + tag: arap::DocTag, + ) { + let (Some(account), Some(e)) = (self.role_account(role), self.entries.get(&entry)) else { + return; + }; + let side = match tag.movement { + arap::Movement::Origin => tag.doc.owed_side(), + _ => kernel::flip(tag.doc.owed_side()), + }; + let matches = e + .lines() + .get(index) + .is_some_and(|l| l.account == account && l.side == side && l.amount.amount == amount); + if matches && !self.line_docs.contains_key(&(entry, index)) { + self.tag_line(entry, index, tag); + } + } + /// Record an invoice without posting: replay support for journaling /// stores. The invoice's entry was already rebuilt from its own /// `entry.created` / `entry.line_added` / `entry.posted` events; this @@ -6788,6 +7193,24 @@ impl Ledger { invoice_id: InvoiceId, amount: Decimal, entry_id: EntryId, + ) -> Result<(), LedgerError> { + self.replay_invoice_payment_at(invoice_id, amount, entry_id, None, Decimal::ZERO, None) + } + + /// [`Self::replay_invoice_payment`] with the line a new event names and + /// the discount it took (plan P4b S6; rules.md B-42 INVARIANT 3). A + /// line the event names but that can't be matched (no such line, the + /// wrong account or side, or already tagged) stays untagged, and the + /// control check lists it: replay never refuses (B-28). + #[doc(hidden)] + pub fn replay_invoice_payment_at( + &mut self, + invoice_id: InvoiceId, + amount: Decimal, + entry_id: EntryId, + line: Option, + discount: Decimal, + discount_line: Option, ) -> Result<(), LedgerError> { let customer = self .invoices @@ -6795,15 +7218,30 @@ impl Ledger { .ok_or(LedgerError::UnknownInvoice(invoice_id))? .customer_id; let doc = arap::DocRef::Invoice(invoice_id); - self.tag_replayed( - entry_id, - &RECEIVABLES, - Some(amount), - arap::DocTag { - doc, - movement: arap::Movement::Payment, - }, - ); + let payment = arap::DocTag { + doc, + movement: arap::Movement::Payment, + }; + match line { + Some(line) => { + self.tag_replayed_at(entry_id, &RECEIVABLES, line, amount, payment); + } + None => self.tag_replayed(entry_id, &RECEIVABLES, Some(amount), payment), + } + if discount > Decimal::ZERO + && let Some(line) = discount_line + { + self.tag_replayed_at( + entry_id, + &RECEIVABLES, + line, + discount, + arap::DocTag { + doc, + movement: arap::Movement::Discount, + }, + ); + } self.tag_customer(entry_id, customer); self.payment_events.entry(doc).or_default().push(amount); self.refresh_doc(doc); @@ -6818,6 +7256,21 @@ impl Ledger { bill_id: BillId, amount: Decimal, entry_id: EntryId, + ) -> Result<(), LedgerError> { + self.replay_bill_payment_at(bill_id, amount, entry_id, None, Decimal::ZERO, None) + } + + /// [`Self::replay_bill_payment`] with the line a new event names and + /// the discount it took; as [`Self::replay_invoice_payment_at`]. + #[doc(hidden)] + pub fn replay_bill_payment_at( + &mut self, + bill_id: BillId, + amount: Decimal, + entry_id: EntryId, + line: Option, + discount: Decimal, + discount_line: Option, ) -> Result<(), LedgerError> { let vendor = self .bills @@ -6825,15 +7278,30 @@ impl Ledger { .ok_or(LedgerError::UnknownBill(bill_id))? .vendor_id; let doc = arap::DocRef::Bill(bill_id); - self.tag_replayed( - entry_id, - &PAYABLES, - Some(amount), - arap::DocTag { - doc, - movement: arap::Movement::Payment, - }, - ); + let payment = arap::DocTag { + doc, + movement: arap::Movement::Payment, + }; + match line { + Some(line) => { + self.tag_replayed_at(entry_id, &PAYABLES, line, amount, payment); + } + None => self.tag_replayed(entry_id, &PAYABLES, Some(amount), payment), + } + if discount > Decimal::ZERO + && let Some(line) = discount_line + { + self.tag_replayed_at( + entry_id, + &PAYABLES, + line, + discount, + arap::DocTag { + doc, + movement: arap::Movement::Discount, + }, + ); + } self.tag_vendor(entry_id, vendor); self.payment_events.entry(doc).or_default().push(amount); self.refresh_doc(doc); @@ -6915,6 +7383,69 @@ impl Ledger { Ok(rows) } + /// One row per open document in an aging, as of `as_of` (plan P4b S6, + /// 8.3.10): the same figures the party rows sum, with the document's + /// own number, dates, terms and bucket. Invoices (`invoices` true) or + /// bills; due date first. + pub fn aging_documents( + &self, + as_of: NaiveDate, + invoices: bool, + ) -> Result, LedgerError> { + let mut out = Vec::new(); + for doc in self.documents() { + let (name, date, due_date, terms) = match doc { + arap::DocRef::Invoice(id) if invoices => { + let i = &self.invoices[&id]; + ( + self.customers.get(&i.customer_id).map(|c| c.name.clone()), + i.date, + i.due_date, + i.terms.as_ref().map(|t| t.describe()), + ) + } + arap::DocRef::Bill(id) if !invoices => { + let b = &self.bills[&id]; + ( + self.vendors.get(&b.vendor_id).map(|v| v.name.clone()), + b.date, + b.due_date, + b.terms.as_ref().map(|t| t.describe()), + ) + } + _ => continue, + }; + let Some(st) = self.doc_state(doc, as_of)? else { + continue; + }; + if st.outstanding <= Decimal::ZERO { + continue; + } + out.push(arap::AgingDocument { + doc, + party: name.unwrap_or_default(), + date, + due_date, + terms, + total: st.total, + paid: st.paid, + discounted: st.discounted, + outstanding: st.outstanding, + bucket: arap::bucket_key(due_date, as_of), + days_overdue: st.overdue_days, + }); + } + out.sort_by_key(|d| { + ( + d.due_date, + match d.doc { + arap::DocRef::Invoice(id) | arap::DocRef::Bill(id) => id, + }, + ) + }); + Ok(out) + } + // ------------------------------------------------------------------ // Bank reconciliation // ------------------------------------------------------------------ diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index a52639e..cdc54ea 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -48,10 +48,11 @@ pub mod tax_capital; pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side}; pub use arap::{ - AgingRow, Bill, BillFields, BillId, ControlCheck, Customer, CustomerId, DocLine, DocRef, - DocState, DocStatus, DocTag, DocumentKind, DocumentLine, DocumentPartKey, DocumentStatus, - EarlyDiscount, Invoice, InvoiceFields, InvoiceId, Movement, PartySnapshot, PaymentDetails, - SendKind, Sent, SentVia, Terms, TermsKind, UnattachedLine, Vendor, VendorId, + AgingDocument, AgingRow, Application, Bill, BillFields, BillId, ControlCheck, Customer, + CustomerId, DocLine, DocRef, DocState, DocStatus, DocTag, DocumentKind, DocumentLine, + DocumentPartKey, DocumentStatus, EarlyDiscount, Fee, Invoice, InvoiceFields, InvoiceId, + Movement, PartySnapshot, PaymentDetails, SendKind, Sent, SentVia, SettleParams, SettlementRow, + Terms, TermsKind, UnattachedLine, Vendor, VendorId, }; pub use assets::{ Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod, @@ -76,8 +77,9 @@ pub use instruments::{Instrument, InstrumentId, InstrumentKind}; pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use kernel::{EntryFlows, Flow}; pub use ledger::{ - ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS, - REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts, + ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYMENT_PROCESSING_FEES, PAYROLL, + PURCHASE_DISCOUNTS, RECEIVABLES, RETAINED_EARNINGS, REVENUE, ReconciliationReport, + SALES_ALLOWANCES, UnclearedLine, control_account, control_accounts, }; pub use money::{Currency, Money}; pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts}; diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index b7929f2..7bb8ce9 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -1928,6 +1928,38 @@ fn discount_max_is_bounded() { kani::cover!(got.is_none(), "an exact bound finer than a whole cent"); } +/// `kernel::settle` is exact: the applications' total is the cash plus the +/// discounts, the deposit is the cash less the fee, and a fee above the +/// cash is refused rather than wrapped. +#[kani::proof] +fn settle_is_exact() { + const N: usize = 3; + let cash: [i32; N] = kani::any(); + let discounts: [i32; N] = kani::any(); + let fee: i32 = kani::any(); + kani::assume(fee >= 0 && fee <= 3_000_000); + for i in 0..N { + kani::assume(cash[i] >= 0 && cash[i] <= 1_000_000); + kani::assume(discounts[i] >= 0 && discounts[i] <= 1_000_000); + } + let total_cash = cash[0] + cash[1] + cash[2]; + let total_discounts = discounts[0] + discounts[1] + discounts[2]; + let got = kernel::settle(&cash, &discounts, fee); + if fee <= total_cash { + let settlement = got.expect("no overflow under these bounds"); + assert_eq!(settlement.applications_total, total_cash + total_discounts); + assert_eq!(settlement.deposit, total_cash - fee); + assert!(settlement.deposit >= 0); + } else { + assert!(got.is_none()); + } + kani::cover!( + got.is_some() && discounts.iter().any(|d| *d > 0) && fee > 0 && fee < total_cash, + "a discount and a fee inside the cash" + ); + kani::cover!(got.is_none(), "a fee above the cash"); +} + /// `kernel::line_amount` is the exact product of a quantity and a unit /// price: never a rounded amount, and None rather than an overflow. #[kani::proof] diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index 0f0c14a..389a472 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -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::>(), + &discounts.iter().map(|&c| dec(c)).collect::>(), + 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. diff --git a/crates/ledger-core/tests/spec_cash_flow.rs b/crates/ledger-core/tests/spec_cash_flow.rs index 57890e5..3874d9d 100644 --- a/crates/ledger-core/tests/spec_cash_flow.rs +++ b/crates/ledger-core/tests/spec_cash_flow.rs @@ -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")); +} diff --git a/crates/ledger-core/tests/spec_documents.rs b/crates/ledger-core/tests/spec_documents.rs index 1e02949..56a2f06 100644 --- a/crates/ledger-core/tests/spec_documents.rs +++ b/crates/ledger-core/tests/spec_documents.rs @@ -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| 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| 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)); +} diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 161e093..513bf58 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1744,8 +1744,11 @@ which an earlier version refuses (S4.2); a `party.updated` with an email or a default terms fact (S4.2); an `invoice.created` or `bill.created` carrying a number, terms, a bill-to or seller snapshot, a customer reference or memo, a vendor reference, a receipt date or a duplicate's -reason, or a line naming its account, quantity or unit price (S4.3); and -S5 to S7's sends, settlements and write-offs. An older version would +reason, or a line naming its account, quantity or unit price (S4.3); in +S6, an `invoice.payment_received` or `bill.paid` carrying `line`, +`discount`, `discount_line`, `discount_reason`, `fee` or `fee_line` (an +older version would drop the discount and the fee, and the balance would +be wrong); and S5 to S7's sends and write-offs. An older version would restore the document without them silently: every invoice line would post to the revenue role and every bill line to general expenses, and every new event type would be refused. A command asks for @@ -3114,3 +3117,98 @@ starts from empty books (legacy lines are covered by the identity coding of `DocRef`), and an account becoming a control account by its first document, holding lines from before, is outside it (those lines are listed as unattached). + +## B-42 A settlement is exact and names its lines + +**bookspec:** `BS-arap-6` + +**Status:** CONVENTION (a payment that settles several documents at once is +still one balanced entry, and each control line carries its document; the +subledger tie-out is B-38's); CHOICE (the gross method for an +early-payment discount, A-8; the processor's fee posted gross, A-4; D-49's +refusal to apportion a discount across a bill's asset lines). ASC +606-10-32-5 to 32-9 are the strict treatment of an expected discount; +this company's size makes the gross method immaterial (A-8). Sources are +cited and not yet vendored, so this rule keeps the CONVENTION label (the +Open list in `README.md`). + +1. **One party, one kind.** A settlement applies to one or more invoices + of one customer, or one or more bills of one vendor, never a mix, and + never the same document twice. Deposits: Σ cash + Σ discounts is what + it applies to the documents; Σ cash − fee is what reaches the deposit + account (`kernel::settle`). The arithmetic is exact: no epsilon, no + rounding, and a fee above the cash is refused. +2. **Bounds.** Each application's cash + discount is at most what its + document owes on every day from the settlement's date on + (`payment_room`), so no day is overpaid. A payment is never dated + before its document, and never in a closed period. +3. **Discounts.** A discount is at most `kernel::discount_max(total, + percent_bp)` for the document's terms and is dated on or before + `terms.discount_by(date)`; outside that it needs a reason, journaled + with the payment (a vendor honouring a late discount). Terms that + state no discount are the same: any discount needs a reason. A discount + on a bill whose lines include an asset or prepaid account is refused + with D-49's remedy: record the bill net of the discount, or reclassify + Dr 6995 Purchase Discounts / Cr the asset. +4. **The fee.** A processor's fee is a customer payment's only. It posts + gross: Dr the processing-fee expense, Dr the deposit account the net, + Cr accounts receivable the gross. Only cash movements count toward a + 1099, so the fee is not a payment to the processor. +5. **Lines and journal.** The entry's rows are the payment lines in + application order, then the discount lines, then the fee line, then + the cash line; a new payment event names the line it tags (`line`) and, + when it took one, its discount and discount line (`discount`, + `discount_line`, `discount_reason`), and the fee's on the first + application's event (`fee`, `fee_line`). Replay tags exactly those + lines; a legacy event with no `line` takes the first untagged control + line for its amount (B-38 item 1). A line replay cannot match stays + untagged and the control check lists it: replay never refuses (B-28). +6. **Exports.** An aging as of a day lists one row per open document with + its number, party, dates, terms, total, paid, discounted, outstanding, + bucket and days overdue, then a tie-out line against its control + account (B-23, B-38 INVARIANT 1). The CPA package and `books.export` + write the same CSV. + +```text +GOAL: a settlement is exact and names its lines. +SOURCE: CONVENTION; ASC 606-10-32-5 to 32-9 noted; CHOICE: the gross + method (A-8), the fee's gross presentation (A-4). +INVARIANTS: + 1. Σ cash + Σ discounts = Σ applications; deposit = Σ cash − fee + (kernel::settle); one party per settlement; one application per + document. + 2. Each application's cash + discount ≤ what the document owes on every + day from the payment's date on. + 3. Each new payment event names the line it tags; replay tags that line; + a legacy event without one takes the first untagged control line for + its amount. + 4. A discount on a bill with an asset or prepaid line is refused. +SCENARIOS: + 1. Two invoices of one customer, 300 and 80, paid 380 by one deposit: + two payment lines, each tagged, one cash line. + 2. "2/10 net 30" bill 100, paid 98 within ten days: Dr A/P 98 (payment) + and 2 (discount), Cr purchase discounts 2, Cr bank 98. + 3. The same discount after the tenth day without a reason: refused, + naming discount_reason; with one: taken and journaled. + 4. An invoice 100 paid through a processor keeping 3: Dr bank 97, Dr + processing fees 3, Cr A/R 100. + 5. A discount on a bill whose line is equipment: refused with the + record-net or reclassify remedy (D-49). +``` + +**Enforced:** `kernel::settle`, `Ledger::{settle, settlement_lines, +check_discount}`, `arap::{Application, Fee, SettleParams, SettlementRow}`, +`Ledger::{replay_invoice_payment_at, replay_bill_payment_at}`, +`Store::settle`, the aging builders (`Ledger::aging_documents`, +`ledger_package::{aging_csv, aging_statement}`). **Checked:** `ledger-core` `tests/spec_documents.rs` +(`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`), +`tests/kernel_bridge.rs::settle_agrees`; Kani `settle_is_exact`; +`ledger-store` `tests/arap_standing.rs` +(`a_settlement_names_its_lines_and_replays`, `settlement_waits_for_documents_v2`, +`legacy_payment_events_keep_the_positional_rule`), `tests/arap_props.rs`; +`ledger-cli` `tests/documents.rs`; `ledger-package`'s aging tie-out test. +The Lean step is `Documents.Step.document` with every control line tagged +(`Documents.settle_unattached`); no new `Step` constructor. diff --git a/docs/verification.md b/docs/verification.md index 9d03108..3863316 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -504,6 +504,25 @@ come from the kernel: No Lean model: these are two arithmetic laws, not a new `Ledger` command (S4.1 adds fields to the existing document commands). +### Settlements (P4b S6) + +34. **A settlement is exact.** The applications' total is Σ cash + Σ + discounts and the deposit is Σ cash − fee, with exact arithmetic; a + fee above the cash, a negative amount, or arrays of different lengths + is refused (None), never wrapped or rounded. The core's posting is a + document step: every control line a settlement posts carries its + document, so `Documents.control_agrees` covers it and no new `Step` + constructor is needed. + - Kani `settle_is_exact` (at i32, fixed arrays of three applications, + a nonzero discount and a nonzero fee covered) · bridge + `settle_agrees` · 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` · Lean + `Documents.settle_posts_a_document_step`, + `Documents.settle_unattached`. + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, @@ -701,7 +720,9 @@ headline theorems) and `just prove-slow` 467 s (8 harnesses, the slowest P4b S4.1's two harnesses were measured on 2026-10-26 (a busy machine); the rest are the 2026-10-22 run. `discount_max_is_bounded`'s 163 s is -Kani's verification time (it runs in `prove-slow`). +Kani's verification time (it runs in `prove-slow`). P4b S6 adds +`settle_is_exact` to `prove` and two Lean theorems to the audit +(`Documents.settle_posts_a_document_step`, `Documents.settle_unattached`). Every harness, the law it proves, the recipe that runs it, and its time in that quiet run (`prove`: each harness's verification time; `prove-slow`: @@ -766,4 +787,5 @@ each harness's whole run, its compile of about 50 s included): | `unrecovered_cost_laws` | 32 | prove | 0 | | `discount_max_is_bounded` | 33 | prove-slow | 163 | | `line_amount_is_exact` | 33 | prove | 6 | +| `settle_is_exact` | 34 | prove | 1 | | `kani_symbolic_copy_canary` | (the checker: "Trusting the checker") | prove | 3 | diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index c015231..d8c690b 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -92,6 +92,8 @@ open Wanderbooks #print axioms Documents.control_agrees #print axioms Documents.control_agrees_through #print axioms Documents.Step.document_unattached +#print axioms Documents.settle_posts_a_document_step +#print axioms Documents.settle_unattached #print axioms Documents.Step.reverse_moves #print axioms Documents.reverse_twice #print axioms Documents.Reachable.unattached_zero diff --git a/verify/lean/Wanderbooks/Documents.lean b/verify/lean/Wanderbooks/Documents.lean index 4575fc3..441ab3f 100644 --- a/verify/lean/Wanderbooks/Documents.lean +++ b/verify/lean/Wanderbooks/Documents.lean @@ -201,6 +201,21 @@ theorem Step.document_unattached (B : Books) (e : List TLine) (h : Tagged B.cont simp [hn, onAcct, hacc] · simp [hn] +/-- **A settlement is a document step**: its entry's every control line +carries its document (a payment or a discount), so the books' rules for +any entry whose control lines are tagged cover it; no new `Step` +constructor is needed (plan P4b S6; rules.md B-42). -/ +theorem settle_posts_a_document_step (B : Books) (e : List TLine) + (h : Tagged B.control e) : Step B { B with lines := B.lines ++ e } := + Step.document B e h + +/-- **A settlement never moves what is unattached** on a control account: +every control line it posts is its document's (B-42, from +`Step.document_unattached`). -/ +theorem settle_unattached (B : Books) (e : List TLine) (h : Tagged B.control e) (a : Nat) + (ha : a ∈ B.control) : unattached a (B.lines ++ e) = unattached a B.lines := + Step.document_unattached B e h a ha + /-- **A reversal moves each document by exactly the opposite of its lines it mirrors** (B-38 INVARIANT 3), and what is unattached likewise: only reversing a legacy hand line changes it. -/