From 10ee0681c1aca692a0404ab318a6f210fbb71bdd Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 10:34:16 -0600 Subject: [PATCH 1/4] 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. --- bookspec/checker/src/rules.rs | 4 +- bookspec/spec/rules-index.md | 8 +- crates/ledger-core/src/arap.rs | 125 +++- crates/ledger-core/src/error.rs | 73 +- crates/ledger-core/src/kernel.rs | 57 ++ crates/ledger-core/src/ledger.rs | 765 +++++++++++++++++---- crates/ledger-core/src/lib.rs | 14 +- crates/ledger-core/src/proofs.rs | 32 + crates/ledger-core/tests/kernel_bridge.rs | 21 + crates/ledger-core/tests/spec_cash_flow.rs | 38 + crates/ledger-core/tests/spec_documents.rs | 377 ++++++++++ docs/spec/rules.md | 102 ++- docs/verification.md | 24 +- verify/lean/Audit.lean | 2 + verify/lean/Wanderbooks/Documents.lean | 15 + 15 files changed, 1511 insertions(+), 146 deletions(-) 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. -/ From 78a2385440c2c649603dad13580e51e407873644 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 10:34:25 -0600 Subject: [PATCH 2/4] feat(store): journal settlements and replay their lines (P4b S6) Store::settle now journals a settlement once, line for line, and one payment event per application, so replay rebuilds the same tags and the projection sees the same entry. A single legacy-shaped payment is still journaled byte for byte; more than one document, a discount or a fee needs documents_v2, and a payment accepted only because an earlier one was reversed still needs document_standing. Each event names the control line it tags (line) and, when it took one, its discount and discount line; the fee is on the first application's event with its line. Replay tags exactly those lines and leaves a line it cannot match untagged, which books.control_check lists; a legacy event without line keeps the first-untagged-control-line rule. Tests: a_settlement_names_its_lines_and_replays (two invoices, a discount and a fee; event indices match the plan; replay equals live), settlement_waits_for_documents_v2 (refused with the feature remedy and no journal line, accepted after the upgrade), and legacy_payment_events_keep_the_positional_rule. arap_props gains a Settle op (two documents, discount, fee), and books_written_under_older_rules_still_open gains a hand-written payment event of the new shape through JournalWriter (8.0.6 item 1). --- crates/ledger-store/src/store.rs | 313 ++++++++++++------ .../tests/arap_props.proptest-regressions | 1 + crates/ledger-store/tests/arap_props.rs | 80 +++++ crates/ledger-store/tests/arap_standing.rs | 214 ++++++++++++ crates/ledger-store/tests/store.rs | 70 +++- 5 files changed, 576 insertions(+), 102 deletions(-) diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 055ff0b..6846a7f 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2859,26 +2859,6 @@ impl Store { }) } - /// Journal an auto-posted two-line entry (debit one account, credit - /// another for the same amount): [`Self::journal_auto_lines`]. - fn journal_auto_entry( - &mut self, - date: NaiveDate, - description: &str, - debit_account: AccountId, - credit_account: AccountId, - amount: Decimal, - ) -> Result<(EntryId, Vec), StoreError> { - self.journal_auto_lines( - date, - description, - &[ - (debit_account, Side::Debit, amount, None), - (credit_account, Side::Credit, amount, None), - ], - ) - } - /// Journal an auto-posted entry of `lines` (account, side, amount, /// project) through the standard entry.created / entry.line_added / /// entry.posted flow, a tagged line's `entry.line_class_set` right @@ -3548,42 +3528,26 @@ impl Store { cash_account: AccountId, details: &ledger_core::PaymentDetails, ) -> Result { - self.rehearse(|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details))?; - self.approval_gate( - |l| l.receive_payment_with(invoice_id, date, amount, cash_account, details), - false, - )?; - // The rehearsal above is the whole check: hand-written mirrors of - // the engine's refusals drift from it (and were never reached). - self.ensure_payment_features(ledger_core::DocRef::Invoice(invoice_id), amount, details)?; - let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; - let description = details.description(&format!("Payment on invoice {invoice_id}")); - let (entry_id, mut journaled) = - self.journal_auto_entry(date, &description, cash_account, ar, amount)?; - journaled.push(self.append_journal( - "invoice.payment_received", - json!({ - "invoice_id": invoice_id, - "date": date.to_string(), - "amount": amount.to_string(), - "cash_account": cash_account, - "entry_id": entry_id, - }), - )?); - if details.instrument.is_some() { - journaled.push(self.append_journal( - "entry.instrument_set", - crate::form1099::entry_instrument_set(entry_id, details.instrument), - )?); - } - let live = - self.ledger - .receive_payment_with(invoice_id, date, amount, cash_account, details)?; - debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); - for line in &journaled { - apply_line_to_sqlite(&self.conn, line)?; - } - Ok(live) + let customer = self + .ledger + .get_invoice(invoice_id) + .ok_or(ledger_core::LedgerError::UnknownInvoice(invoice_id))? + .customer_id; + self.settle(ledger_core::SettleParams { + date, + description: details.description(&format!("Payment on invoice {invoice_id}")), + cash_account, + party: customer, + applications: vec![ledger_core::Application { + doc: ledger_core::DocRef::Invoice(invoice_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), + }) } /// Pay a vendor bill ([`Self::pay_bill_with`], with no payment method @@ -3617,35 +3581,140 @@ impl Store { cash_account: AccountId, details: &ledger_core::PaymentDetails, ) -> Result { - self.rehearse(|l| l.pay_bill_with(bill_id, date, amount, cash_account, details))?; - self.approval_gate( - |l| l.pay_bill_with(bill_id, date, amount, cash_account, details), - false, - )?; - self.ensure_payment_features(ledger_core::DocRef::Bill(bill_id), amount, details)?; - let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; - let description = details.description(&format!("Payment on bill {bill_id}")); + let vendor = self + .ledger + .get_bill(bill_id) + .ok_or(ledger_core::LedgerError::UnknownBill(bill_id))? + .vendor_id; + self.settle(ledger_core::SettleParams { + date, + description: details.description(&format!("Payment on bill {bill_id}")), + cash_account, + party: vendor, + applications: vec![ledger_core::Application { + doc: ledger_core::DocRef::Bill(bill_id), + cash: amount, + discount: Decimal::ZERO, + discount_reason: None, + }], + fee: None, + instrument: details.instrument, + reference: details.reference.clone(), + }) + } + + /// Settle one or more invoices or bills in one entry (plan P4b S6; + /// rules.md B-42): the core posts it ([`ledger_core::Ledger::settle`]); + /// this journals its lines once and one `invoice.payment_received` or + /// `bill.paid` per application, each naming the control line it tags, + /// then the payment method. A single legacy-shaped payment is + /// journaled exactly as before; more than one application, a discount + /// or a fee needs `documents_v2`, and a payment accepted only because + /// an earlier one was reversed needs `document_standing`. + pub fn settle(&mut self, params: ledger_core::SettleParams) -> Result { + self.rehearse(|l| l.settle(params.clone()))?; + self.approval_gate(|l| l.settle(params.clone()), false)?; + self.ensure_payment_features(¶ms)?; + let kind = match params.applications.first().map(|a| a.doc) { + Some(ledger_core::DocRef::Bill(_)) => ledger_core::DocumentKind::Bill, + _ => ledger_core::DocumentKind::Invoice, + }; + let control_role = match kind { + ledger_core::DocumentKind::Invoice => ledger_core::RECEIVABLES, + ledger_core::DocumentKind::Bill => ledger_core::PAYABLES, + }; + self.ensure_control_account(&control_role)?; + if params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + { + let role = match kind { + ledger_core::DocumentKind::Invoice => ledger_core::SALES_ALLOWANCES, + ledger_core::DocumentKind::Bill => ledger_core::PURCHASE_DISCOUNTS, + }; + self.ensure_control_account(&role)?; + } + if params.fee.as_ref().is_some_and(|f| f.account.is_none()) { + self.ensure_control_account(&ledger_core::PAYMENT_PROCESSING_FEES)?; + } + let rows = self.ledger.settlement_lines(¶ms)?; + let simple: Vec<(AccountId, Side, Decimal, Option)> = rows + .iter() + .map(|&(account, side, amount, _)| (account, side, amount, None)) + .collect(); + 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_id, mut journaled) = - self.journal_auto_entry(date, &description, ap, cash_account, amount)?; - journaled.push(self.append_journal( - "bill.paid", - json!({ - "bill_id": bill_id, - "date": date.to_string(), - "amount": amount.to_string(), - "cash_account": cash_account, + self.journal_auto_lines(params.date, &description, &simple)?; + let documents_v2 = params.applications.len() > 1 + || params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + || params.fee.is_some(); + let discounts = params + .applications + .iter() + .filter(|a| a.discount > Decimal::ZERO) + .count(); + let fee_line = params.applications.len() + discounts; + let (event, id_key) = match kind { + ledger_core::DocumentKind::Invoice => ("invoice.payment_received", "invoice_id"), + ledger_core::DocumentKind::Bill => ("bill.paid", "bill_id"), + }; + for (i, app) in params.applications.iter().enumerate() { + let doc_id = match app.doc { + ledger_core::DocRef::Invoice(id) | ledger_core::DocRef::Bill(id) => id, + }; + let mut payload = json!({ + "date": params.date.to_string(), + "amount": app.cash.to_string(), + "cash_account": params.cash_account, "entry_id": entry_id, - }), - )?); - if details.instrument.is_some() { + }); + payload[id_key] = json!(doc_id); + if documents_v2 { + payload["line"] = json!(i); + if app.discount > Decimal::ZERO { + let line = params.applications.len() + + params.applications[..i] + .iter() + .filter(|a| a.discount > Decimal::ZERO) + .count(); + payload["discount"] = json!(app.discount.to_string()); + payload["discount_line"] = json!(line); + if let Some(reason) = &app.discount_reason { + payload["discount_reason"] = json!(reason); + } + } + if let Some(fee) = ¶ms.fee + && i == 0 + { + payload["fee"] = json!(fee.amount.to_string()); + payload["fee_line"] = json!(fee_line); + } + } + journaled.push(self.append_journal(event, payload)?); + } + if params.instrument.is_some() { journaled.push(self.append_journal( "entry.instrument_set", - crate::form1099::entry_instrument_set(entry_id, details.instrument), + crate::form1099::entry_instrument_set(entry_id, params.instrument), )?); } - let live = self - .ledger - .pay_bill_with(bill_id, date, amount, cash_account, details)?; + let live = self.ledger.settle(params)?; debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); for line in &journaled { apply_line_to_sqlite(&self.conn, line)?; @@ -3653,29 +3722,39 @@ impl Store { Ok(live) } - /// The journal features a payment of `amount` on `doc` needs - /// ([`Self::receive_payment_with`] SCENARIOS 2 and 3): document_standing - /// when more than an older version thinks is still owed (the - /// document's total less every payment event it recorded, reversed - /// or not, its line tagged or not: `Ledger::payments_recorded`), - /// payment_instruments when it names a payment method. + /// The journal features a settlement needs (rules.md B-27, B-42): + /// `documents_v2` for more than one application, a discount or a fee + /// (an older version would drop them, and the balance would be wrong), + /// `document_standing` when an application pays more than an older + /// version thinks is still owed (the document's total less every + /// payment event it recorded, reversed or not, its line tagged or + /// not), and `payment_instruments` when it names a payment method. fn ensure_payment_features( &self, - doc: ledger_core::DocRef, - amount: Decimal, - details: &ledger_core::PaymentDetails, + params: &ledger_core::SettleParams, ) -> Result<(), StoreError> { - let total = match doc { - ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()), - ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()), - }; - if let Some(total) = total { - let older_owed = total?.checked_sub(self.ledger.payments_recorded(doc)?); - if older_owed.is_none_or(|owed| amount > owed) { - self.ensure_feature(crate::format::DOCUMENT_STANDING)?; + if params.applications.len() > 1 + || params + .applications + .iter() + .any(|a| a.discount > Decimal::ZERO) + || params.fee.is_some() + { + self.ensure_feature(crate::format::DOCUMENTS_V2)?; + } + for app in ¶ms.applications { + let total = match app.doc { + ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()), + ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()), + }; + if let Some(total) = total { + let older_owed = total?.checked_sub(self.ledger.payments_recorded(app.doc)?); + if older_owed.is_none_or(|owed| app.cash > owed) { + self.ensure_feature(crate::format::DOCUMENT_STANDING)?; + } } } - if details.instrument.is_some() { + if params.instrument.is_some() { self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?; } Ok(()) @@ -6401,6 +6480,31 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> { .ok_or_else(|| StoreError::JournalCorrupt(format!("missing/invalid '{key}'"))) } +/// An optional integer a newer payment event carries (its `line` or +/// `discount_line`): absent or null is none; anything else must be an +/// integer. +fn get_opt_usize(p: &Value, key: &str) -> Result, StoreError> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .map(|n| Some(n as usize)) + .ok_or_else(|| StoreError::JournalCorrupt(format!("invalid '{key}'"))), + } +} + +/// An optional decimal string a newer payment event carries (its +/// `discount`): absent or null is zero. +fn get_opt_decimal(p: &Value, key: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(Decimal::ZERO), + Some(v) => v + .as_str() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| StoreError::JournalCorrupt(format!("invalid '{key}'"))), + } +} + /// Replay one journal line into a ledger, asserting recorded ids. Used by /// hydration, `verify`, and `rebuild`: the journal is the script, the engine /// is the actor. @@ -6980,23 +7084,30 @@ fn replay_line( )?; } "invoice.payment_received" => { - // The payment entry was already rebuilt; this only moves the - // invoice's paid amount and status. - ledger.replay_invoice_payment( + // The payment entry was already rebuilt; this only tags its + // receivables lines (the line the event names, or the first + // untagged one for a legacy event) and moves the invoice. + ledger.replay_invoice_payment_at( get_u64(p, "invoice_id")?, get_str(p, "amount")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?, get_u64(p, "entry_id")?, + get_opt_usize(p, "line")?, + get_opt_decimal(p, "discount")?, + get_opt_usize(p, "discount_line")?, )?; } "bill.paid" => { - ledger.replay_bill_payment( + ledger.replay_bill_payment_at( get_u64(p, "bill_id")?, get_str(p, "amount")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?, get_u64(p, "entry_id")?, + get_opt_usize(p, "line")?, + get_opt_decimal(p, "discount")?, + get_opt_usize(p, "discount_line")?, )?; } "fx.rate_set" => { diff --git a/crates/ledger-store/tests/arap_props.proptest-regressions b/crates/ledger-store/tests/arap_props.proptest-regressions index 788a3a7..cdae27a 100644 --- a/crates/ledger-store/tests/arap_props.proptest-regressions +++ b/crates/ledger-store/tests/arap_props.proptest-regressions @@ -5,3 +5,4 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 704ab49c9bea84359475c3afa56c3354275a353670fc71156fba151f846f4f73 # shrinks to first = [Create(false, 0, 1), Create(false, 0, 1)], then = [Close(1), Hand(false, 1)] +cc f678814ec34db3abef40116e17e562fe5abe652c09f12600e19841beaf9d6fe6 # shrinks to first = [Create(false, 0, 13902), Settle { a: 0, b: 0, n: 0, cash: 6676, discount: 551, fee: 0 }], then = [] diff --git a/crates/ledger-store/tests/arap_props.rs b/crates/ledger-store/tests/arap_props.rs index a014f20..cb4ebe7 100644 --- a/crates/ledger-store/tests/arap_props.rs +++ b/crates/ledger-store/tests/arap_props.rs @@ -68,6 +68,16 @@ enum Op { Close(u8), /// A hand line on receivables (true) or payables: always refused. Hand(bool, i64), + /// One payment settling two documents of one party, the first with a + /// discount and (on invoices) a processor's fee. + Settle { + a: usize, + b: usize, + n: u8, + cash: i64, + discount: i64, + fee: i64, + }, } fn arb_op() -> impl Strategy { @@ -79,6 +89,15 @@ fn arb_op() -> impl Strategy { 1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)), 1 => (1u8..4).prop_map(Op::Close), 1 => (any::(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)), + 2 => (0usize..8, 0usize..8, 0u8..90, 1i64..20_000, 0i64..2_000, 0i64..500) + .prop_map(|(a, b, n, cash, discount, fee)| Op::Settle { + a, + b, + n, + cash, + discount, + fee, + }), ] } @@ -187,6 +206,67 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec) -> Result<( }) .map(drop) } + Op::Settle { .. } if docs.is_empty() => Ok(()), + Op::Settle { + a, + b, + n, + cash, + discount, + fee, + } => { + let first = docs[a % docs.len()]; + let mut second = docs[b % docs.len()]; + if second == first && docs.len() > 1 { + second = docs[(b + 1) % docs.len()]; + } + let same_kind = matches!( + (first, second), + (DocRef::Invoice(_), DocRef::Invoice(_)) | (DocRef::Bill(_), DocRef::Bill(_)) + ); + if !same_kind { + // One party and one kind per settlement: not this pair. + Ok(()) + } else { + let party = match first { + DocRef::Invoice(id) => s.ledger().get_invoice(id).map(|i| i.customer_id), + DocRef::Bill(id) => s.ledger().get_bill(id).map(|v| v.vendor_id), + } + .unwrap(); + let invoices = matches!(first, DocRef::Invoice(_)); + let money = cents(cash); + let apps = vec![ + ledger_core::Application { + doc: first, + cash: money, + discount: cents(discount), + discount_reason: (discount > 0) + .then(|| format!("a late discount, n {n}")), + }, + ledger_core::Application { + doc: second, + cash: money, + discount: Decimal::ZERO, + discount_reason: None, + }, + ]; + s.settle(ledger_core::SettleParams { + date: day(n), + description: "two documents, one payment".into(), + cash_account: k.bank, + party, + applications: apps, + fee: (invoices && fee > 0).then_some(ledger_core::Fee { + amount: cents(fee), + account: None, + vendor: None, + }), + instrument: None, + reference: None, + }) + .map(drop) + } + } Op::Hand(receivables, c) => { let code = if receivables { "1200" } else { "2000" }; match s.ledger().chart().find_by_code(code).map(|a| a.id) { diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index eec3711..c01f884 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -1054,3 +1054,217 @@ fn preview_invoice_and_bill_journal_nothing() { ); s.verify().unwrap(); } + +/// A settlement of two invoices, a discount and a processor's fee: one +/// entry whose events name the lines they tag, every figure derived, and +/// the books replaying to the same lines (plan P4b S6; rules.md B-42 +/// INVARIANTS 1 and 3). +#[test] +fn a_settlement_names_its_lines_and_replays() { + use ledger_core::{Application, DocRef, Fee, SettleParams}; + let dir = test_dir("settlement-lines"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let c = s.add_customer("Fernhill Labs").unwrap(); + let first = s + .create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 7_000)]) + .unwrap(); + let second = s + .create_invoice( + c, + d(2026, 4, 2), + d(2026, 5, 2), + vec![line("Support", 2_000)], + ) + .unwrap(); + let from = journal_len(&dir.0); + let entry = s + .settle(SettleParams { + date: d(2026, 4, 10), + description: "One deposit, two invoices".into(), + cash_account: bank, + party: c, + applications: vec![ + Application { + doc: DocRef::Invoice(first), + cash: cents(6_000), + discount: cents(1_000), + discount_reason: Some("vendor honoured the early discount late".into()), + }, + Application { + doc: DocRef::Invoice(second), + cash: cents(2_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: Some(Fee { + amount: cents(500), + account: None, + vendor: None, + }), + instrument: None, + reference: Some("ch_42".into()), + }) + .unwrap(); + let got = since(&dir.0, from); + // The two role accounts created on demand, then the entry, then one + // event per application. + assert_eq!( + got.iter().filter(|(t, _)| t == "account.added").count(), + 2, + "{got:?}" + ); + assert_eq!( + got.iter() + .filter(|(t, _)| t == "invoice.payment_received") + .count(), + 2 + ); + assert_eq!( + got.iter().filter(|(t, _)| t == "entry.line_added").count(), + 6 + ); + // One credit per invoice's cash, one for the discount, the fee, the + // discount expense and the net deposit: the rows the event lines name. + let events: Vec<&Value> = got + .iter() + .filter(|(t, _)| t == "invoice.payment_received") + .map(|(_, p)| p) + .collect(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["line"], json!(0)); + assert_eq!(events[0]["discount"], json!("10.00")); + assert_eq!(events[0]["discount_line"], json!(2)); + assert_eq!(events[0]["fee"], json!("5.00")); + assert_eq!(events[0]["fee_line"], json!(3)); + assert_eq!(events[1]["line"], json!(1)); + assert!(events[1].get("discount").is_none()); + assert!(events[1].get("fee").is_none()); + let live = s.ledger().clone(); + let ar = live.control_check(d(2026, 4, 10)).unwrap(); + let receivables = ar.iter().find(|c| c.role == "receivables").unwrap(); + assert_eq!(receivables.balance, cents(0)); + assert_eq!(receivables.documents_total, cents(0)); + assert_eq!(receivables.difference, Decimal::ZERO); + for (id, paid, discounted) in [(first, 6_000, 1_000), (second, 2_000, 0)] { + let st = live.invoice_state(id, d(2026, 4, 10)).unwrap().unwrap(); + assert_eq!( + (st.paid, st.discounted, st.outstanding), + (cents(paid), cents(discounted), Decimal::ZERO) + ); + } + assert_eq!(s.ledger().get_entry(entry).unwrap().lines().len(), 6); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!( + live.differs_from(s.ledger()).is_empty(), + "{:?}", + live.differs_from(s.ledger()) + ); + s.verify().unwrap(); +} + +/// A settlement of more than one document, a discount or a fee needs +/// `documents_v2`: refused, journaling nothing, in books without it, and +/// accepted after the owner's upgrade, replaying to the same books (plan +/// P4b S6; rules.md B-27, B-42). +#[test] +fn settlement_waits_for_documents_v2() { + use ledger_core::{Application, DocRef, SettleParams}; + let dir = fixture("settlement-v2"); + let mut s = Store::open(&dir.0).unwrap(); + assert!( + !s.journal_format() + .has_feature(ledger_store::format::DOCUMENTS_V2) + ); + let bank = s.ledger().chart().find_by_code("1000").unwrap().id; + let head = s.head(); + let params = |discount: Decimal| SettleParams { + date: d(2026, 2, 15), + description: "Two bills, one payment".into(), + cash_account: bank, + party: 1, + applications: vec![ + Application { + doc: DocRef::Bill(1), + cash: cents(7_000), + discount, + discount_reason: (!discount.is_zero()).then_some("late discount".into()), + }, + Application { + doc: DocRef::Bill(2), + cash: cents(4_000), + discount: Decimal::ZERO, + discount_reason: None, + }, + ], + fee: None, + instrument: None, + reference: None, + }; + match s.settle(params(Decimal::ZERO)) { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature documents_v2") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + assert_eq!(s.head(), head, "nothing journaled"); + s.upgrade_format().unwrap(); + // The discount is outside the invoice's terms (it has none), so it + // needs its reason; the payment is also accepted only because the + // earlier one was reversed, which needs document_standing too. + s.settle(params(cents(1_000))).unwrap(); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!( + live.differs_from(s.ledger()).is_empty(), + "{:?}", + live.differs_from(s.ledger()) + ); + let st = s.ledger().bill_state(1, d(2026, 12, 31)).unwrap().unwrap(); + assert_eq!(st.status, ledger_core::DocStatus::Paid); +} + +/// Legacy payment events carry no `line`, so replay keeps the positional +/// rule: the first untagged control line for the payment's amount, in the +/// order the events come (plan P4b S6; rules.md B-42 INVARIANT 3). +#[test] +fn legacy_payment_events_keep_the_positional_rule() { + use ledger_core::{DocRef, Movement}; + let dir = fixture("legacy-positional"); + let s = Store::open(&dir.0).unwrap(); + let payments: Vec = std::fs::read_to_string(dir.0.join(JOURNAL_FILE)) + .unwrap() + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .filter(|v| v["type"] == json!("invoice.payment_received")) + .map(|v| v["payload"].clone()) + .collect(); + assert!(!payments.is_empty()); + for p in &payments { + assert!(p.get("line").is_none(), "{p}"); + } + let lines = s.ledger().doc_lines(DocRef::Invoice(3)); + assert_eq!( + lines + .iter() + .map(|l| (l.movement, l.amount)) + .collect::>(), + [ + (Movement::Origin, cents(30_000)), + (Movement::Payment, cents(-12_000)) + ] + ); + assert_eq!( + s.ledger() + .line_doc(lines[1].entry, lines[1].line) + .unwrap() + .doc, + DocRef::Invoice(3) + ); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 412fecc..34d7961 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1864,6 +1864,8 @@ fn books_written_under_older_rules_still_open() { (4, "4000", "Revenue", "Revenue"), (5, "2000", "Accounts Payable", "Liability"), (6, "6500", "Supplies", "Expense"), + (7, "4100", "Refunds and Allowances", "Revenue"), + (8, "1020", "Bank", "Asset"), ] { w.append( "account.added", @@ -1946,13 +1948,55 @@ fn books_written_under_older_rules_still_open() { "memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}), ) .unwrap(); + // P4b S6's new shape, hand-written as this version writes it: a + // payment event naming its line and the discount line it also tags. + // This reader tags both; the version before it would drop the discount + // and read the invoice paid above its total. + w.append( + "entry.created", + json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1"}), + ) + .unwrap(); + for (account, side, amount) in [ + (3, "credit", "98.99"), + (3, "credit", "1.00"), + (7, "debit", "1.00"), + (8, "debit", "98.99"), + ] { + w.append( + "entry.line_added", + json!({"id": 4, "account": account, "side": side, "amount": amount, + "currency": "USD"}), + ) + .unwrap(); + } + w.append( + "entry.posted", + json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1", + "reversal_of": null, + "lines": [ + {"account": 3, "side": "credit", "amount": "98.99", "currency": "USD"}, + {"account": 3, "side": "credit", "amount": "1.00", "currency": "USD"}, + {"account": 7, "side": "debit", "amount": "1.00", "currency": "USD"}, + {"account": 8, "side": "debit", "amount": "98.99", "currency": "USD"} + ]}), + ) + .unwrap(); + w.append( + "invoice.payment_received", + json!({"invoice_id": 1, "date": "2026-04-05", "amount": "98.99", + "cash_account": 8, "entry_id": 4, "line": 0, + "discount": "1.00", "discount_line": 1, + "discount_reason": "the customer paid the rest late"}), + ) + .unwrap(); drop(w); // Opening rebuilds from the journal; it must accept all of it. let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); let store = Store::open(&dir).expect("older books still open"); store.verify().unwrap(); assert_eq!(store.journal_format().format, 1); - assert_eq!(store.ledger().chart().len(), 6); + assert_eq!(store.ledger().chart().len(), 8); assert_eq!(store.ledger().list_periods().len(), 2); let lines = store.ledger().get_entry(1).unwrap().lines(); assert_eq!(lines.len(), 2); @@ -2007,6 +2051,30 @@ fn books_written_under_older_rules_still_open() { ); assert_eq!(bill.memo.as_deref(), Some("A4 paper")); assert_eq!(bill.lines[0].account, Some(6)); + // The S6 shape: the invoice reads paid and discounted, and replay + // tagged the lines the event names. + let st = store + .ledger() + .invoice_state(1, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()) + .unwrap() + .unwrap(); + assert_eq!( + (st.status, st.paid, st.discounted, st.outstanding), + ( + ledger_core::DocStatus::Paid, + "98.99".parse::().unwrap(), + "1.00".parse::().unwrap(), + Decimal::ZERO + ) + ); + assert_eq!( + store.ledger().line_doc(4, 0).unwrap().movement, + ledger_core::Movement::Payment + ); + assert_eq!( + store.ledger().line_doc(4, 1).unwrap().movement, + ledger_core::Movement::Discount + ); } /// The books an earlier wanderbooks wrote, one directory each under From 57f04dbb0c5e77e3e60a2b8f4e3f05ab563e5dcd Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 10:34:35 -0600 Subject: [PATCH 3/4] feat(mcp): payments on terms, bills due, and the agings' tie-out (P4b S6) books.receive_payment and books.pay_bill take applications, each document's cash with an optional discount and reason, and a customer payment may carry the processor's fee; the result names every document it settled and what reached the account. books.bills_due lists what to pay and the running total by due date. The agings take detail and return the control account's tie-out, and books.export writes ar_aging/ap_aging. The handlers live in tools/settlements.rs (the EM's split, so S4.4's documents.rs stays free); documents.rs keeps the documents, the void and the control check. docs/mcp-interface.md documents every changed tool and the new one; docs/architecture.md names the group; docs/testing.md records the tests and P12's mutation targets. Old names (document_id, cash_account) still work; the output keeps remaining/status for one document. A fee's processor is named in the description, not tagged as the entry's vendor: ledger tags are entry-wide and tagging would move the customer's party totals and 1099-NEC. The handbook's invoices-and-bills section gains the payment, discount, fee and bounce-replacement procedures (S4.5 can extend it). Over the wire: one_payment_settles_two_invoices, late_discount_needs_a_reason, a_fee_posts_gross_to_receivables_and_net_to_the_bank, card_paid_bill_is_not_1099_nec, multi_document_payment_counts_once_for_1099_and_party_totals, overpayment_refused_with_the_excess, bills_due_orders_by_due_and_discount and the_aging_export_ties_out. Terms cannot yet be journaled on a document (S4.3) and a bill line's account is not an MCP field (S4.4), so bill_on_terms_with_discount, equipment_bill_payment_is_investing_cash and the discount half of bills_due are core tests until those land; the unit's As-built block records this. --- crates/ledger-cli/tests/documents.rs | 401 +++++++++++++ crates/ledger-cli/tests/mcp_journeys.rs | 4 +- crates/ledger-cli/tests/spec_mcp.rs | 7 +- crates/ledger-mcp/handbook/format-upgrade.md | 1 + .../ledger-mcp/handbook/invoices-and-bills.md | 63 ++ crates/ledger-mcp/src/handbook.rs | 4 + crates/ledger-mcp/src/scopes.rs | 3 +- crates/ledger-mcp/src/server.rs | 3 + crates/ledger-mcp/src/server_tests.rs | 3 +- crates/ledger-mcp/src/tools/documents.rs | 193 +----- crates/ledger-mcp/src/tools/reports.rs | 58 +- crates/ledger-mcp/src/tools/settlements.rs | 557 ++++++++++++++++++ crates/ledger-mcp/src/types.rs | 236 +++++++- docs/architecture.md | 7 +- docs/mcp-interface.md | 71 ++- docs/testing.md | 77 +++ plan/served.md | 10 +- 17 files changed, 1467 insertions(+), 231 deletions(-) create mode 100644 crates/ledger-cli/tests/documents.rs create mode 100644 crates/ledger-mcp/handbook/invoices-and-bills.md create mode 100644 crates/ledger-mcp/src/tools/settlements.rs diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs new file mode 100644 index 0000000..776c559 --- /dev/null +++ b/crates/ledger-cli/tests/documents.rs @@ -0,0 +1,401 @@ +//! Paying on terms over the wire (plan P4b S6; rules.md B-42): one payment +//! settling several documents, the early-payment discount, the processor's +//! fee, the bills-due list, the agings' tie-out and the exports. Every +//! call goes through the books server as an agent reaches it (common +//! INVARIANT 1). + +mod common; + +use common::{Books, Wire, answer_of}; +use serde_json::{Value, json}; + +/// Call a tool and unwrap its answer. +fn ok(w: &mut Wire, tool: &str, args: Value) -> Value { + answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{tool}: {e}")) +} + +/// The refusal's text. +fn err(w: &mut Wire, tool: &str, args: Value) -> String { + answer_of(&w.call(tool, args)).expect_err(&format!("{tool} was accepted")) +} + +struct Setup { + _books: Books, + wire: Wire, + customer: u64, + vendor: u64, +} + +/// Bank 1000, card 2050, capital 3000, a customer and a vendor, then a +/// session. +fn setup(tag: &str) -> Setup { + let books = Books::new(tag); + let mut wire = books.session("documents"); + ok( + &mut wire, + "books.add_account", + json!({"code": "1000", "name": "Bank", "account_type": "asset"}), + ); + ok( + &mut wire, + "books.add_account", + json!({"code": "2050", "name": "Card", "account_type": "liability"}), + ); + ok( + &mut wire, + "books.add_account", + json!({"code": "3000", "name": "Capital", "account_type": "equity"}), + ); + let customer = ok( + &mut wire, + "books.add_customer", + json!({"name": "Fernhill Labs"}), + )["id"] + .as_u64() + .unwrap(); + let vendor = ok( + &mut wire, + "books.add_vendor", + json!({"name": "Tobin Harlow Supply"}), + )["id"] + .as_u64() + .unwrap(); + Setup { + _books: books, + wire, + customer, + vendor, + } +} + +impl Setup { + fn invoice(&mut self, date: &str, due: &str, amount: &str) -> u64 { + ok( + &mut self.wire, + "books.create_invoice", + json!({"party_id": self.customer, "date": date, "due_date": due, + "lines": [{"description": "Consulting", "amount": amount}]}), + )["id"] + .as_u64() + .unwrap() + } + + fn bill(&mut self, date: &str, due: &str, amount: &str) -> u64 { + ok( + &mut self.wire, + "books.create_bill", + json!({"party_id": self.vendor, "date": date, "due_date": due, + "lines": [{"description": "Paper", "amount": amount}]}), + )["id"] + .as_u64() + .unwrap() + } + + fn line(&mut self, entry: u64, code: &str) -> (String, String, String) { + let e = ok( + &mut self.wire, + "books.get_entry", + json!({"entry_id": entry}), + ); + for l in e["lines"].as_array().unwrap() { + if l["code"] == json!(code) { + return ( + l["side"].as_str().unwrap().to_string(), + l["amount"].as_str().unwrap().to_string(), + code.to_string(), + ); + } + } + panic!("{code} not in entry {entry}: {e}"); + } +} + +/// One deposit settles two invoices of one customer: one entry, one +/// receivable line per invoice, each tagged with its own invoice, and the +/// output names both documents (B-42 INVARIANTS 1 and 3). +#[test] +fn one_payment_settles_two_invoices() { + let mut s = setup("settle-two"); + let first = s.invoice("2026-02-01", "2026-03-01", "500.00"); + let second = s.invoice("2026-02-05", "2026-04-30", "80.00"); + let out = ok( + &mut s.wire, + "books.receive_payment", + json!({"date": "2026-02-10", "deposit_to": "1000", + "reference": "one deposit", + "applications": [ + {"invoice": first, "amount": "300.00"}, + {"invoice": second, "amount": "80.00"} + ]}), + ); + assert_eq!(out["deposited"], json!("380.00")); + let refs: Vec<&str> = out["documents"] + .as_array() + .unwrap() + .iter() + .map(|d| d["ref"].as_str().unwrap()) + .collect(); + assert_eq!( + refs, + [format!("invoice:{first}"), format!("invoice:{second}")] + ); + assert_eq!(out["documents"][0]["outstanding"], json!("200.00")); + assert_eq!(out["documents"][1]["status"], json!("paid")); + // The entry: one deposit line, two receivable lines. + let entry = out["entry_id"].as_u64().unwrap(); + let e = ok(&mut s.wire, "books.get_entry", json!({"entry_id": entry})); + let lines = e["lines"].as_array().unwrap(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0]["side"], json!("credit")); + assert_eq!(lines[0]["amount"], json!("300.00")); + assert_eq!(lines[1]["side"], json!("credit")); + assert_eq!(lines[1]["amount"], json!("80.00")); + // Each invoice's timeline shows its own payment from this entry. + for (id, paid) in [(first, "300.00"), (second, "80.00")] { + let d = ok( + &mut s.wire, + "books.document", + json!({"ref": format!("invoice:{id}"), "as_of": "2026-12-31"}), + ); + let payment = d["timeline"] + .as_array() + .unwrap() + .iter() + .find(|t| t["kind"] == json!("payment")) + .unwrap(); + assert_eq!(payment["entry_id"].as_u64().unwrap(), entry); + assert_eq!(payment["amount"], json!(format!("-{paid}"))); + } +} + +/// A discount outside the document's terms needs a reason; with one the +/// payment is taken and recorded (B-42 INVARIANT 2; the terms are S4.3's +/// to journal, so this bill states none). +#[test] +fn late_discount_needs_a_reason() { + let mut s = setup("late-discount"); + let bill = s.bill("2026-02-01", "2026-03-03", "100.00"); + let applications = |reason: Option<&str>| { + let mut a = json!([{"bill": bill, "amount": "98.00", "discount": "2.00"}]); + if let Some(reason) = reason { + a[0]["discount_reason"] = json!(reason); + } + a + }; + let why = err( + &mut s.wire, + "books.pay_bill", + json!({"date": "2026-02-05", "paid_from": "1000", "applications": applications(None)}), + ); + assert!(why.contains("needs a reason"), "{why}"); + assert!(why.contains("discount_reason"), "{why}"); + ok( + &mut s.wire, + "books.pay_bill", + json!({"date": "2026-02-05", "paid_from": "1000", + "applications": applications(Some("the vendor honoured it late"))}), + ); +} + +/// A fee posts gross (A-4): the deposit gets the net, the fee its expense, +/// and A/R is credited the gross (B-42 INVARIANT 1). +#[test] +fn a_fee_posts_gross_to_receivables_and_net_to_the_bank() { + let mut s = setup("fee-gross"); + let invoice = s.invoice("2026-02-01", "2026-03-01", "100.00"); + let out = ok( + &mut s.wire, + "books.receive_payment", + json!({"date": "2026-02-05", "deposit_to": "1000", + "invoice": invoice, "amount": "100.00", + "fee": {"amount": "3.00"}}), + ); + assert_eq!(out["deposited"], json!("97.00")); + assert_eq!(out["status"], json!("paid")); + let entry = out["entry_id"].as_u64().unwrap(); + assert_eq!( + s.line(entry, "1200"), + ("credit".into(), "100.00".into(), "1200".into()) + ); + assert_eq!( + s.line(entry, "6060"), + ("debit".into(), "3.00".into(), "6060".into()) + ); + assert_eq!( + s.line(entry, "1000"), + ("debit".into(), "97.00".into(), "1000".into()) + ); +} + +/// A bill paid by card is excluded from 1099-NEC (D-40): the worksheet +/// counts it as card, not as paid. +#[test] +fn card_paid_bill_is_not_1099_nec() { + let mut s = setup("card-1099"); + ok( + &mut s.wire, + "books.add_instrument", + json!({"kind": "business_card", "label": "Visa", "last4": "4242", "account": "2050"}), + ); + let bill = s.bill("2026-02-01", "2026-03-03", "100.00"); + ok( + &mut s.wire, + "books.pay_bill", + json!({"date": "2026-02-05", "paid_from": "2050", "bill": bill, "amount": "100.00", + "instrument": "Visa"}), + ); + let worksheet = ok( + &mut s.wire, + "books.vendor_1099", + json!({"year": 2026, "cash_accounts": ["1000"]}), + ); + let line = worksheet["vendors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["vendor_id"] == json!(s.vendor)) + .unwrap(); + assert_eq!(line["paid"], json!("0.00"), "{worksheet}"); + assert_eq!(line["excluded_card"], json!("100.00"), "{worksheet}"); +} + +/// Two bills paid in one entry count once toward 1099s and the party's +/// totals (B-42; 8.3.16's multi-document risk). +#[test] +fn multi_document_payment_counts_once_for_1099_and_party_totals() { + let mut s = setup("multi-1099"); + let first = s.bill("2026-02-01", "2026-03-03", "100.00"); + let second = s.bill("2026-02-02", "2026-03-04", "200.00"); + ok( + &mut s.wire, + "books.pay_bill", + json!({"date": "2026-02-05", "paid_from": "1000", + "applications": [ + {"bill": first, "amount": "100.00"}, + {"bill": second, "amount": "200.00"} + ]}), + ); + let worksheet = ok( + &mut s.wire, + "books.vendor_1099", + json!({"year": 2026, "cash_accounts": ["1000"]}), + ); + let line = worksheet["vendors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["vendor_id"] == json!(s.vendor)) + .unwrap(); + assert_eq!(line["paid"], json!("300.00"), "{worksheet}"); + let parties = ok(&mut s.wire, "books.parties", json!({"year": 2026})); + let party = parties["parties"] + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == json!("Tobin Harlow Supply")) + .unwrap(); + assert_eq!(party["paid"], json!("300.00"), "{parties}"); +} + +/// An overpayment is refused showing what the document owes and the +/// remedy for the excess (A-12, D-49). +#[test] +fn overpayment_refused_with_the_excess() { + let mut s = setup("overpayment"); + let invoice = s.invoice("2026-02-01", "2026-03-01", "100.00"); + let why = err( + &mut s.wire, + "books.receive_payment", + json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "150.00"}), + ); + assert!(why.contains("150.00"), "{why}"); + assert!(why.contains("100.00"), "{why}"); + assert!(why.contains("excess"), "{why}"); + assert!(why.contains("2300"), "{why}"); +} + +/// The bills-due list orders by due date and gives the running total +/// through each date; the agings' detail rows and their tie-out agree with +/// the control check (8.3.10; B-42). +#[test] +fn bills_due_orders_by_due_and_discount() { + let mut s = setup("bills-due"); + s.bill("2026-02-01", "2026-02-20", "100.00"); + s.bill("2026-02-02", "2026-02-10", "250.00"); + s.bill("2026-02-03", "2026-03-10", "400.00"); + let due = ok( + &mut s.wire, + "books.bills_due", + json!({"through": "2026-02-28", "as_of": "2026-02-05"}), + ); + let dates: Vec<&str> = due["bills"] + .as_array() + .unwrap() + .iter() + .map(|b| b["due_date"].as_str().unwrap()) + .collect(); + assert_eq!(dates, ["2026-02-10", "2026-02-20"]); + assert_eq!( + due["by_date"], + json!([ + {"date": "2026-02-10", "amount": "250.00"}, + {"date": "2026-02-20", "amount": "350.00"} + ]) + ); + // The discount fields exist only when the bill's terms give one, which + // S4.3 journals: without them they are absent, not zero. + assert!(due["bills"][0].get("discount_by").is_none()); + let aging = ok( + &mut s.wire, + "books.ap_aging", + json!({"as_of": "2026-02-05", "detail": true}), + ); + assert_eq!(aging["control"]["difference"], json!("0.00"), "{aging}"); + assert_eq!(aging["control"]["documents_total"], json!("750.00")); + let rows = aging["documents"].as_array().unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0]["ref"], json!(format!("bill:{}", 2))); + assert_eq!(rows[0]["bucket"], json!("current")); + // A/R is empty and still ties. + let ar = ok( + &mut s.wire, + "books.ar_aging", + json!({"as_of": "2026-02-05", "detail": true}), + ); + assert_eq!(ar["control"]["balance"], json!("0.00")); + assert_eq!(ar["control"]["difference"], json!("0.00")); + assert!(ar.get("documents").is_none()); +} + +/// The aging exports are the same CSV the CPA package carries: one row per +/// document and a tie-out line, and the CPA package's CSVs tie to the +/// control account (8.3.10; ledger-package's test repeats the tie-out). +#[test] +fn the_aging_export_ties_out() { + let mut s = setup("aging-export"); + let invoice = s.invoice("2026-02-01", "2026-03-01", "500.00"); + ok( + &mut s.wire, + "books.receive_payment", + json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "200.00"}), + ); + let out = ok( + &mut s.wire, + "books.export", + json!({"format": "csv", "report": "ar_aging", "as_of": "2026-02-28"}), + ); + let csv = s.wire.fetch_text(&out["file"]); + let mut lines = csv.lines(); + assert_eq!( + lines.next(), + Some( + "number,party,date,due_date,terms,total,paid,discounted,outstanding,bucket,days_overdue" + ) + ); + assert!(lines.next().unwrap().contains("500.00")); + let total = lines.next().unwrap(); + assert!(total.starts_with("TOTAL,,,,,,,,"), "{total}"); + assert!(total.contains("300.00"), "{total}"); + assert!(total.contains("GL 1200 300.00"), "{total}"); + assert!(total.ends_with("difference 0.00"), "{total}"); +} diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index c8f33cc..decf75f 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -125,7 +125,7 @@ fn mcp_end_to_end() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 122, "expected 122 tools, got {}", tools.len()); + assert_eq!(tools.len(), 123, "expected 123 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); @@ -1222,6 +1222,8 @@ fn mcp_statement_pdfs_and_cpa_package() { [ "1099-worksheet-2026.csv", "README.txt", + "ap_aging.csv", + "ar_aging.csv", "balance-sheet.pdf", "balance-sheet.xlsx", "cash-flow.pdf", diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index ede855a..f778737 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -878,6 +878,7 @@ fn happy_path(name: &str) -> Mcp { ); m.ok("books.ar_aging", json!({"as_of": "2026-06-30"})); m.ok("books.ap_aging", json!({"as_of": "2026-06-30"})); + m.ok("books.bills_due", json!({"through": "2026-06-30"})); // P4b S1: a document voided, and the control accounts' tie-out. let dup = m.ok( "books.create_invoice", @@ -1176,10 +1177,14 @@ fn happy_path(name: &str) -> Mcp { ("csv", "general_ledger"), ("json", "members_equity"), ("csv", "tax_summary"), + ("csv", "ar_aging"), + ("xlsx", "ar_aging"), + ("pdf", "ap_aging"), ] { m.ok( "books.export", - json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}), + json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31", + "as_of": "2026-12-31"}), ); } // The IIF takes as_of (every posted entry from the first through it), diff --git a/crates/ledger-mcp/handbook/format-upgrade.md b/crates/ledger-mcp/handbook/format-upgrade.md index 36007b5..9bd5be8 100644 --- a/crates/ledger-mcp/handbook/format-upgrade.md +++ b/crates/ledger-mcp/handbook/format-upgrade.md @@ -6,3 +6,4 @@ - Only the owner can agree to the upgrade, with the owner passphrase: in the app, which asks each time it opens the books, or with `wanderbooks books upgrade ID` in a terminal. No tool can, and you never ask for, take or type the passphrase. Tell the human, in plain words: the books wait for their go-ahead, what was refused, and that saying no keeps the books working as they are. - Before it upgrades, the server makes a backup of the books, their vault and keys included, and proves it whole; a backup that can't be made or proven upgrades nothing. Then the books move to the new format in one step, on the record. - After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours. +- This upgrade is `documents_v2`: invoice numbers, terms and line accounts (S4), sent records (S5), payments that settle several documents with early-payment discounts and a processor's fee (S6), and write-offs (S7). Until it is given, those writes are refused and everything else works as before. diff --git a/crates/ledger-mcp/handbook/invoices-and-bills.md b/crates/ledger-mcp/handbook/invoices-and-bills.md new file mode 100644 index 0000000..138652e --- /dev/null +++ b/crates/ledger-mcp/handbook/invoices-and-bills.md @@ -0,0 +1,63 @@ +# Invoices and bills + +Invoices are what customers owe the company; bills are what it owes +vendors. Every figure on them is read from the ledger lines tagged with +them, so a reversal, a void or a bounced payment is right by +construction: never edit one. + +## 2. Record a customer payment + +- Ask for the date the money arrived, where it landed, and what it paid. + One deposit may settle several invoices of the same customer: pass + `applications` with each invoice and its cash amount. +- Record how it moved (`instrument`) and the processor's or bank's + reference. The books record payments; they never collect them. +- If the customer paid more than the invoices owe, the payment is + refused: record the excess as a plain entry to 2300 Customer Deposits + (no role) and apply it when the work is invoiced (D-49, A-12). +- A check or note marked "paid in full" of a disputed amount earns the + UCC §3-311 warning. Record only the payment; a lawyer or the payer's + agreement settles the rest. + +## 3. Pay a vendor bill + +- Ask for the date, the account it left, and which bills it pays. One + payment may settle several bills of one vendor (`applications`). +- Record the method and the reference. A card payment is tagged as card + and stays out of 1099-NEC (D-40). +- Use `books.bills_due` for what is due and when, with each vendor's + early-payment discount window. + +### Taking an early-payment discount + +- A bill on "2/10 net 30" takes 2% off if paid within ten days. Pass the + discount beside the cash (`discount`); the books post the gross method: + the bill is credited in full, the discount goes to Purchase Discounts + (6995), and the bank gets the rest. +- A discount at most the terms allow and dated on or before their + discount date needs no reason. A larger or later one needs + `discount_reason` (a vendor who honours it anyway); it is journaled. +- A bill with an asset or prepaid line refuses a discount: record the + bill net of the discount, or reclassify with an entry Dr 6995 Purchase + Discounts / Cr the asset account (D-49). Never split the discount across + the asset lines. + +## 5. A processor's fee + +- The books never charge or collect; a processor does, and keeps a fee. + Pass `fee` on `books.receive_payment` (amount, and optionally the + expense account and the processor's vendor). +- The entry is gross: Dr the deposit account the net, Dr the + processing-fee expense the fee, Cr Accounts Receivable the gross (A-4). + Only cash movements count toward a 1099, so the fee is not a payment to + the processor. + +## A replacement payment arrives before a bounce is known + +1. Park it: a plain entry Dr the bank / Cr 2300 Customer Deposits (no + role), described with the customer and what it is for. +2. When the first payment bounces, reverse it (`books.reverse_entry`) + dated the bounce day; it un-pays its invoices. +3. Apply the parked money with `books.receive_payment`, + `deposit_to: "2300"`, dated on or after the bounce, against the + invoices it pays. The 2300 balance returns to zero. diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index beb69b2..8e757a4 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -79,6 +79,10 @@ pub const SECTIONS: &[(&str, &str)] = &[ include_str!("../handbook/parties-and-tins.md"), ), ("1099", include_str!("../handbook/1099.md")), + ( + "invoices-and-bills", + include_str!("../handbook/invoices-and-bills.md"), + ), ("cpa-handoff", include_str!("../handbook/cpa-handoff.md")), ( "settings-and-conventions", diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 2814e5d..69fd038 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 122] = [ +pub const TABLE: [(&str, Needs); 123] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -171,6 +171,7 @@ pub const TABLE: [(&str, Needs); 122] = [ ("books.document", R), ("books.ar_aging", R), ("books.ap_aging", R), + ("books.bills_due", R), ("books.set_fx_rate", W), ("books.run_payroll", P), ("books.remit_payroll_taxes", P), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index dce5b8b..3db8d3d 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -566,6 +566,7 @@ impl BooksServer { + Self::instruments_router() + Self::projects_router() + Self::documents_router() + + Self::settlements_router() + Self::entries_router() + Self::reports_router() + Self::assets_router() @@ -1422,6 +1423,8 @@ mod reports_tools; mod schedules; #[path = "tools/settings.rs"] mod settings_tools; +#[path = "tools/settlements.rs"] +mod settlements; #[path = "tools/tax.rs"] mod tax; diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index 2f12cfb..d2b8622 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -42,7 +42,7 @@ fn annotations_complete() { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 122] = [ + let table: [(&str, Hints); 123] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -150,6 +150,7 @@ fn annotations_complete() { ("books.document", R), ("books.ar_aging", R), ("books.ap_aging", R), + ("books.bills_due", R), ("books.set_fx_rate", W), ("books.run_payroll", D), ("books.remit_payroll_taxes", D), diff --git a/crates/ledger-mcp/src/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs index 7b057c0..f14fbeb 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -1,46 +1,11 @@ //! `books.documents`' group: the A/R and A/P documents (invoices and -//! bills), the payments and voids against them, the read tools -//! (`books.documents`, `books.document`), the control check and the -//! agings. Split whole out of `server.rs` (plan/served.md 8.2 U0a): the -//! tools, their names, descriptions and rules are unchanged -//! (`docs/mcp-interface.md`; rules.md B-38). +//! bills), their voids, the read tools (`books.documents`, +//! `books.document`) and the control check. Split whole out of +//! `server.rs` (plan/served.md 8.2 U0a); the payments and the agings live +//! in `tools/settlements.rs` (P4b S6). use super::*; -/// A payment's method and reference from its parameters (the method by -/// id or label, books.instruments). -fn payment_details( - store: &Store, - p: &PaymentParams, -) -> Result { - Ok(ledger_core::PaymentDetails { - instrument: p - .instrument - .as_deref() - .map(|r| resolve_instrument(store, r)) - .transpose()?, - reference: p.reference.clone(), - }) -} - -/// What a payment tool answers: the document's figures after it, derived -/// from its lines that stand (rules.md B-38), as of the end of the books. -fn payment_out( - ledger: &Ledger, - doc: ledger_core::DocRef, - entry_id: u64, -) -> Result, String> { - let st = ledger - .doc_state(doc, chrono::NaiveDate::MAX) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("{} not found", doc_label(doc)))?; - Ok(Json(PaymentOut { - entry_id, - remaining: money(st.outstanding), - status: st.status.key().to_string(), - })) -} - /// A document's id. fn doc_id(doc: ledger_core::DocRef) -> u64 { match doc { @@ -69,14 +34,6 @@ fn movement_key(m: ledger_core::Movement) -> &'static str { } } -/// A document's kind and id for people ("invoice 7"). -fn doc_label(doc: ledger_core::DocRef) -> String { - match doc { - ledger_core::DocRef::Invoice(id) => format!("invoice {id}"), - ledger_core::DocRef::Bill(id) => format!("bill {id}"), - } -} - /// An invoice's or bill's lines as the tool takes them: each amount /// parsed and each project resolved ([`resolve_class`]) before anything is /// written. @@ -208,74 +165,6 @@ impl BooksServer { })) } - #[tool( - name = "books.receive_payment", - title = "Receive an invoice payment", - description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R, the receivable line tagged with the invoice (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments) and the processor's or bank's reference when known. The books record payments; they never collect them (D-45). Refused above what it owes, before its date, or into an account that isn't cash.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn receive_payment( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let cash = resolve_account(&store, &p.cash_account)?; - let details = payment_details(&store, &p)?; - let entry_id = store - .receive_payment_with( - p.document_id, - parse_date(&p.date)?, - parse_amount(&p.amount)?, - cash, - &details, - ) - .map_err(|e| e.to_string())?; - payment_out( - store.ledger(), - ledger_core::DocRef::Invoice(p.document_id), - entry_id, - ) - } - - #[tool( - name = "books.pay_bill", - title = "Pay a bill", - description = "Record a payment of a vendor bill. Posts Dr A/P / Cr Cash, the payable line tagged with the bill (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments: a card payment is left out of 1099-NEC) and the payment's reference when known. The books record payments; they never make them (D-45). Refused above what it owes, before its date, or from an account that isn't cash.", - annotations( - read_only_hint = false, - destructive_hint = true, - open_world_hint = false - ) - )] - async fn pay_bill( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let mut store = self.lock()?; - let cash = resolve_account(&store, &p.cash_account)?; - let details = payment_details(&store, &p)?; - let entry_id = store - .pay_bill_with( - p.document_id, - parse_date(&p.date)?, - parse_amount(&p.amount)?, - cash, - &details, - ) - .map_err(|e| e.to_string())?; - payment_out( - store.ledger(), - ledger_core::DocRef::Bill(p.document_id), - entry_id, - ) - } - #[tool( name = "books.void_document", title = "Void an invoice or bill", @@ -556,78 +445,4 @@ impl BooksServer { let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; Ok(Json(control_check_out(ledger, as_of, &checks))) } - - #[tool( - name = "books.ar_aging", - title = "Receivables aging", - description = described( - "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+).", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn ar_aging(&self, params: Parameters) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; - let rows = store - .ledger() - .ar_aging(as_of) - .map_err(|e| e.to_string())? - .into_iter() - .map(|r| { - let total = r.total().map_err(|e| e.to_string())?.to_string(); - Ok(AgingRowOut { - name: r.name, - current: money(r.current), - days_30: money(r.days_30), - days_60: money(r.days_60), - days_90: money(r.days_90), - days_90_plus: money(r.days_90_plus), - total, - }) - }) - .collect::>()?; - Ok(Json(AgingOut { - as_of: as_of.to_string(), - rows, - })) - } - - #[tool( - name = "books.ap_aging", - title = "Payables aging", - description = described( - "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+).", - ledger_core::calendar::PeriodKind::AsOf - ), - annotations(read_only_hint = true, open_world_hint = false) - )] - async fn ap_aging(&self, params: Parameters) -> Result, String> { - let p = params.0; - let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; - let rows = store - .ledger() - .ap_aging(as_of) - .map_err(|e| e.to_string())? - .into_iter() - .map(|r| { - let total = r.total().map_err(|e| e.to_string())?.to_string(); - Ok(AgingRowOut { - name: r.name, - current: money(r.current), - days_30: money(r.days_30), - days_60: money(r.days_60), - days_90: money(r.days_90), - days_90_plus: money(r.days_90_plus), - total, - }) - }) - .collect::>()?; - Ok(Json(AgingOut { - as_of: as_of.to_string(), - rows, - })) - } } diff --git a/crates/ledger-mcp/src/tools/reports.rs b/crates/ledger-mcp/src/tools/reports.rs index 3d9fe83..5eb7965 100644 --- a/crates/ledger-mcp/src/tools/reports.rs +++ b/crates/ledger-mcp/src/tools/reports.rs @@ -848,7 +848,7 @@ impl BooksServer { #[tool( name = "books.export", title = "Export a report", - description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).", + description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).", annotations(read_only_hint = true, open_world_hint = false) )] async fn export(&self, params: Parameters) -> Result, String> { @@ -911,9 +911,11 @@ impl BooksServer { "fixed_asset_register" => { self.export_fixed_asset_register(&format, p.as_of.as_deref())? } + "ar_aging" => self.export_aging(&format, true, p.as_of.as_deref())?, + "ap_aging" => self.export_aging(&format, false, p.as_of.as_deref())?, _ => { return Err(format!( - "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, or fixed_asset_register", + "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, fixed_asset_register, ar_aging, or ap_aging", p.report )); } @@ -1205,14 +1207,64 @@ impl BooksServer { .map_err(|e| e.to_string())?; report::members_equity(&me) } + "ar_aging" => { + ledger_package::aging_statement(ledger, true, self.as_of(p.as_of.as_deref())?) + .map_err(|e| e.to_string())? + } + "ap_aging" => { + ledger_package::aging_statement(ledger, false, self.as_of(p.as_of.as_deref())?) + .map_err(|e| e.to_string())? + } _ => { return Err(format!( - "bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, or tax_summary" + "bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, tax_summary, ar_aging, or ap_aging" )); } }) } + /// The receivables or payables aging as CSV or JSON (plan P4b S6, + /// 8.3.10): one row per open document, then the tie-out line; the + /// same CSV the CPA package carries. + fn export_aging( + &self, + format: &str, + invoices: bool, + as_of: Option<&str>, + ) -> Result { + let as_of = self.as_of(as_of)?; + let store = self.lock()?; + if format == "json" { + let docs = store + .ledger() + .aging_documents(as_of, invoices) + .map_err(|e| e.to_string())?; + let rows: Vec = docs + .iter() + .map(|d| { + serde_json::json!({ + "number": match d.doc { + ledger_core::DocRef::Invoice(id) => store.ledger().get_invoice(id).and_then(|i| i.number.clone()), + ledger_core::DocRef::Bill(id) => store.ledger().get_bill(id).and_then(|b| b.vendor_ref.clone()), + }, + "party": d.party, + "date": d.date.to_string(), + "due_date": d.due_date.to_string(), + "terms": d.terms, + "total": money(d.total), + "paid": money(d.paid), + "discounted": money(d.discounted), + "outstanding": money(d.outstanding), + "bucket": d.bucket, + "days_overdue": d.days_overdue, + }) + }) + .collect(); + return serde_json::to_string_pretty(&rows).map_err(|e| e.to_string()); + } + ledger_package::aging_csv(store.ledger(), invoices, as_of) + } + /// Render a report to XLSX bytes via ledger-import. fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result, String> { use ledger_import::{XlsxReport, report_to_xlsx}; diff --git a/crates/ledger-mcp/src/tools/settlements.rs b/crates/ledger-mcp/src/tools/settlements.rs new file mode 100644 index 0000000..90ca18e --- /dev/null +++ b/crates/ledger-mcp/src/tools/settlements.rs @@ -0,0 +1,557 @@ +//! `books.receive_payment`, `books.pay_bill`, `books.bills_due` and the +//! agings: paying on terms (plan P4b S6; rules.md B-42). A payment settles +//! one or several invoices (or bills) of one party, with the early-payment +//! discounts their terms allow and the processor's fee on a customer +//! payment posted gross. Split from `tools/documents.rs` so S4.4's work +//! there and this slice's do not collide (plan/served.md 8.2 P4b S6). + +use super::*; + +/// A document as the payment tools and the agings name it ("invoice:7", +/// "bill:12"). +fn doc_ref_text(doc: ledger_core::DocRef) -> String { + match doc { + ledger_core::DocRef::Invoice(id) => format!("invoice:{id}"), + ledger_core::DocRef::Bill(id) => format!("bill:{id}"), + } +} + +/// A document's number, when the books have one: an invoice's issued +/// number, or a bill's vendor invoice number. +fn doc_number(ledger: &Ledger, doc: ledger_core::DocRef) -> Option { + match doc { + ledger_core::DocRef::Invoice(id) => ledger.get_invoice(id).and_then(|i| i.number.clone()), + ledger_core::DocRef::Bill(id) => ledger.get_bill(id).and_then(|b| b.vendor_ref.clone()), + } +} + +/// A document's party (the customer of an invoice, the vendor of a bill). +fn doc_party(ledger: &Ledger, doc: ledger_core::DocRef) -> Result { + match doc { + ledger_core::DocRef::Invoice(id) => ledger + .get_invoice(id) + .map(|i| i.customer_id) + .ok_or_else(|| format!("no invoice {id}: see books.documents")), + ledger_core::DocRef::Bill(id) => ledger + .get_bill(id) + .map(|b| b.vendor_id) + .ok_or_else(|| format!("no bill {id}: see books.documents")), + } +} + +/// The one id a payment tool's single form names: `invoice`/`bill`, or +/// the older `document_id`. Both at once is refused unless they agree. +fn single_document(primary: Option, alias: Option, what: &str) -> Result { + match (primary, alias) { + (Some(a), Some(b)) if a != b => Err(format!( + "pass {what} or document_id, not both: {} and {b} differ", + a + )), + (Some(id), _) | (None, Some(id)) => Ok(id), + (None, None) => Err(format!( + "pass {what} (or an applications list): see books.documents" + )), + } +} + +/// The one account a payment tool's single form names: `deposit_to`/ +/// `paid_from`, or the older `cash_account`. +fn single_account( + primary: &Option, + alias: &Option, + what: &str, +) -> Result { + match (primary, alias) { + (Some(a), Some(b)) if a != b => Err(format!( + "pass {what} or cash_account, not both: {a:?} and {b:?} differ" + )), + (Some(a), _) | (None, Some(a)) => Ok(a.clone()), + (None, None) => Err(format!("pass {what} (or cash_account)")), + } +} + +/// Every document a payment tool's parameters name, as the core takes +/// them: the single form (`invoice`/`bill` with `amount`) or the +/// applications list, never both. +fn applications( + store: &Store, + p: &PaymentParams, + invoices: bool, +) -> Result, String> { + if !p.applications.is_empty() { + if p.amount.is_some() { + return Err( + "pass amount with invoice or bill, or an applications list, not both".into(), + ); + } + return p + .applications + .iter() + .map(|a| { + let (id, other) = if invoices { + (a.invoice, a.bill) + } else { + (a.bill, a.invoice) + }; + if other.is_some() { + return Err(format!( + "an application names {} only, not {}", + if invoices { "invoice" } else { "bill" }, + if invoices { "bill" } else { "invoice" } + )); + } + let id = id.ok_or_else(|| { + format!( + "an application needs its {}", + if invoices { "invoice" } else { "bill" } + ) + })?; + let doc = if invoices { + ledger_core::DocRef::Invoice(id) + } else { + ledger_core::DocRef::Bill(id) + }; + // Refuse an unknown document here, before anything is + // journaled, with the list tool named. + doc_party(store.ledger(), doc)?; + Ok(ledger_core::Application { + doc, + cash: parse_amount(&a.amount)?, + discount: match &a.discount { + Some(d) => parse_amount(d)?, + None => Decimal::ZERO, + }, + discount_reason: a.discount_reason.clone(), + }) + }) + .collect(); + } + let id = single_document( + if invoices { p.invoice } else { p.bill }, + p.document_id, + if invoices { "invoice" } else { "bill" }, + )?; + let amount = p + .amount + .as_deref() + .ok_or_else(|| "pass amount (or an applications list)".to_string())?; + let doc = if invoices { + ledger_core::DocRef::Invoice(id) + } else { + ledger_core::DocRef::Bill(id) + }; + doc_party(store.ledger(), doc)?; + Ok(vec![ledger_core::Application { + doc, + cash: parse_amount(amount)?, + discount: Decimal::ZERO, + discount_reason: None, + }]) +} + +/// The settlement one payment tool describes, and the warnings its +/// parameters add. +fn settlement( + store: &Store, + p: &PaymentParams, + invoices: bool, +) -> Result<(ledger_core::SettleParams, Vec), String> { + if p.applications.is_empty() { + if invoices && p.bill.is_some() { + return Err("books.receive_payment takes invoice, not bill".into()); + } + if !invoices && p.invoice.is_some() { + return Err("books.pay_bill takes bill, not invoice".into()); + } + } + let apps = applications(store, p, invoices)?; + let first = apps + .first() + .ok_or_else(|| "a payment needs at least one invoice or bill".to_string())? + .doc; + let party = doc_party(store.ledger(), first)?; + let cash_account = resolve_account( + store, + &single_account( + if invoices { + &p.deposit_to + } else { + &p.paid_from + }, + &p.cash_account, + if invoices { "deposit_to" } else { "paid_from" }, + )?, + )?; + let details = ledger_core::PaymentDetails { + instrument: p + .instrument + .as_deref() + .map(|r| resolve_instrument(store, r)) + .transpose()?, + reference: p.reference.clone(), + }; + let count = apps.len(); + let base = if invoices { + if count == 1 { + format!( + "Payment on invoice {}", + match first { + ledger_core::DocRef::Invoice(id) => id, + ledger_core::DocRef::Bill(_) => 0, + } + ) + } else { + format!("Payment on {count} invoices") + } + } else if count == 1 { + format!( + "Payment on bill {}", + match first { + ledger_core::DocRef::Bill(id) => id, + ledger_core::DocRef::Invoice(_) => 0, + } + ) + } else { + format!("Payment on {count} bills") + }; + let mut description = details.description(&base); + if !invoices && p.fee.is_some() { + return Err("a processor's fee belongs on a customer payment, not on paying a bill".into()); + } + let fee = match &p.fee { + None => None, + Some(fee) => { + let fee = ledger_core::Fee { + amount: parse_amount(&fee.amount)?, + account: fee + .account + .as_deref() + .map(|a| resolve_account(store, a)) + .transpose()?, + vendor: fee + .vendor + .as_deref() + .map(|v| resolve_vendor(store, v)) + .transpose()?, + }; + if let Some(vendor) = fee.vendor + && let Some(v) = store.ledger().get_vendor(vendor) + { + description = format!("{description} (processing fee to {})", v.name); + } + Some(fee) + } + }; + let mut warnings = Vec::new(); + if p.paid_in_full_marking { + warnings.push( + "The check or note is marked paid in full of a disputed amount. Cashing it may settle \ + the whole claim (UCC §3-311): the books record only this payment; a lawyer or the \ + payer's agreement settles the rest." + .to_string(), + ); + } + Ok(( + ledger_core::SettleParams { + date: parse_date(&p.date)?, + description, + cash_account, + party, + applications: apps, + fee, + instrument: details.instrument, + reference: details.reference, + }, + warnings, + )) +} + +/// What a payment settled: each document's figures after it (derived from +/// its lines that stand, rules.md B-38) and what reached or left the +/// account. +fn payment_out( + ledger: &Ledger, + entry_id: u64, + params: &ledger_core::SettleParams, +) -> Result, String> { + let mut documents = Vec::with_capacity(params.applications.len()); + let mut deposited = Decimal::ZERO; + for app in ¶ms.applications { + deposited += app.cash; + let st = ledger + .doc_state(app.doc, chrono::NaiveDate::MAX) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("{} not found", doc_ref_text(app.doc)))?; + documents.push(PaidDocumentOut { + r#ref: doc_ref_text(app.doc), + number: doc_number(ledger, app.doc), + outstanding: money(st.outstanding), + status: st.status.key().to_string(), + }); + } + let fee = params.fee.as_ref().map_or(Decimal::ZERO, |f| f.amount); + deposited -= fee; + let (remaining, status) = match documents.as_slice() { + [one] => (Some(one.outstanding.clone()), Some(one.status.clone())), + _ => (None, None), + }; + Ok(Json(PaymentOut { + entry_id, + documents, + deposited: money(deposited), + remaining, + status, + warnings: Vec::new(), + })) +} + +#[tool_router(router = settlements_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.receive_payment", + title = "Receive a payment", + description = "Record a customer payment against one or several invoices of one customer. Posts Dr the deposit account (the cash less any processor fee), Dr payment-processing fees the fee, Dr sales allowances the discounts, Cr A/R for each invoice's cash and discount, each receivable line tagged with its invoice. An early-payment discount may be at most the invoice's terms allow and dated by their discount date; a later or larger one needs discount_reason. Name instrument (books.instruments) and the processor's reference. Refused above what an invoice owes, before its date, or into an account that is not cash. The books record payments; they never collect them (D-45).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn receive_payment( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let (settle, warnings) = settlement(&store, &p, true)?; + let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?; + let mut out = payment_out(store.ledger(), entry_id, &settle)?.0; + out.warnings = warnings; + Ok(Json(out)) + } + + #[tool( + name = "books.pay_bill", + title = "Pay a bill", + description = "Record paying one or several vendor bills of one vendor. Posts Dr A/P for each bill's cash and discount, each payable line tagged with its bill; Cr the payer account the cash and purchase discounts the discounts. An early-payment discount may be at most the bill's terms allow and dated by their discount date; a later or larger one needs discount_reason, and a bill with an asset or prepaid line refuses one (record it net, or reclassify Dr 6995 / Cr the asset). Name instrument (books.instruments: a card payment is left out of 1099-NEC) and the reference. Refused above what a bill owes, before its date, or from an account that is not cash. The books record payments; they never make them (D-45).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn pay_bill( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let (settle, _) = settlement(&store, &p, false)?; + let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?; + payment_out(store.ledger(), entry_id, &settle) + } + + #[tool( + name = "books.bills_due", + title = "Bills due", + description = described( + "What to pay and when: every open bill due on or before a day (through; omitted: today), due date first, with its vendor, the vendor's invoice number and what it still owes, and the early-payment discount each vendor's terms give (the last day it is open, the most off, and what to pay to take it). by_date is the running total of what falls due through each date, for planning. The figures are read from the bills' ledger lines that stand (B-38).", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn bills_due( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = self.as_of(p.as_of.as_deref())?; + let through = p + .through + .as_deref() + .map(parse_date) + .transpose()? + .unwrap_or_else(today); + let store = self.lock()?; + let ledger = store.ledger(); + let mut bills: Vec<(DueBillOut, Decimal)> = Vec::new(); + for bill in ledger.bills() { + let doc = ledger_core::DocRef::Bill(bill.id); + let Some(st) = ledger.doc_state(doc, as_of).map_err(|e| e.to_string())? else { + continue; + }; + if st.outstanding <= Decimal::ZERO || bill.due_date > through { + continue; + } + let discounts = bill.terms.as_ref().and_then(|terms| { + let early = terms.discount()?; + let by = terms.discount_by(bill.date)?; + let max = ledger_core::kernel::discount_max(bill.total().ok()?, early.percent_bp)?; + Some((by, max)) + }); + let (discount_by, max_discount, pay) = match discounts { + Some((by, max)) if by >= as_of => ( + Some(by.to_string()), + Some(money(max)), + Some(money(st.outstanding - max)), + ), + _ => (None, None, None), + }; + bills.push(( + DueBillOut { + r#ref: doc_ref_text(doc), + vendor: ledger + .get_vendor(bill.vendor_id) + .map(|v| v.name.clone()) + .unwrap_or_default(), + vendor_ref: bill.vendor_ref.clone(), + due_date: bill.due_date.to_string(), + outstanding: money(st.outstanding), + discount_by, + max_discount, + pay_by_discount_date_amount: pay, + }, + st.outstanding, + )); + } + bills.sort_by_key(|(b, _)| (b.due_date.clone(), b.r#ref.clone())); + let mut by_date = Vec::new(); + let mut running = Decimal::ZERO; + for (bill, owed) in &bills { + running += owed; + match by_date.last_mut() { + Some(DueDateTotalOut { date, amount }) if *date == bill.due_date => { + *amount = money(running); + } + _ => by_date.push(DueDateTotalOut { + date: bill.due_date.clone(), + amount: money(running), + }), + } + } + Ok(Json(BillsDueOut { + as_of: as_of.to_string(), + through: through.to_string(), + bills: bills.into_iter().map(|(b, _)| b).collect(), + by_date, + })) + } + + #[tool( + name = "books.ar_aging", + title = "Receivables aging", + description = described( + "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+), with the receivables account's tie-out (its balance against what its invoices owe, its lines no invoice's, the difference: always 0.00). detail: true adds one row per invoice, with its number, dates and terms.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn ar_aging(&self, params: Parameters) -> Result, String> { + aging(self, params.0, true) + } + + #[tool( + name = "books.ap_aging", + title = "Payables aging", + description = described( + "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+), with the payables account's tie-out (its balance against what its bills owe, its lines no bill's, the difference: always 0.00). detail: true adds one row per bill, with its number, dates and terms.", + ledger_core::calendar::PeriodKind::AsOf + ), + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn ap_aging(&self, params: Parameters) -> Result, String> { + aging(self, params.0, false) + } +} + +/// An aging of invoices (`invoices` true) or bills, with its tie-out. +fn aging(server: &BooksServer, p: AgingParams, invoices: bool) -> Result, String> { + let as_of = server.as_of(p.as_of.as_deref())?; + let store = server.lock()?; + let ledger = store.ledger(); + let rows = if invoices { + ledger.ar_aging(as_of) + } else { + ledger.ap_aging(as_of) + } + .map_err(|e| e.to_string())? + .into_iter() + .map(|r| { + let total = r.total().map_err(|e| e.to_string())?.to_string(); + Ok(AgingRowOut { + name: r.name, + current: money(r.current), + days_30: money(r.days_30), + days_60: money(r.days_60), + days_90: money(r.days_90), + days_90_plus: money(r.days_90_plus), + total, + }) + }) + .collect::, String>>()?; + let documents = if p.detail { + ledger + .aging_documents(as_of, invoices) + .map_err(|e| e.to_string())? + .into_iter() + .map(|d| { + Ok(AgingDocumentOut { + r#ref: doc_ref_text(d.doc), + number: doc_number(ledger, d.doc), + party: d.party, + date: d.date.to_string(), + due_date: d.due_date.to_string(), + terms: d.terms, + total: money(d.total), + paid: money(d.paid), + discounted: money(d.discounted), + outstanding: money(d.outstanding), + bucket: d.bucket.to_string(), + days_overdue: d.days_overdue, + }) + }) + .collect::, String>>()? + } else { + Vec::new() + }; + let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; + let role = if invoices { "receivables" } else { "payables" }; + let control = match checks.iter().find(|c| c.role == role) { + Some(check) => { + let account = ledger.chart().get(check.account); + AgingControlOut { + account: match account { + Some(a) => format!("{} {}", a.code, a.name), + None => String::new(), + }, + balance: money(check.balance), + documents_total: money(check.documents_total), + unattached: check + .unattached + .iter() + .map(|u| UnattachedLineOut { + entry_id: u.entry, + line: u.line, + date: u.date.to_string(), + amount: money(u.amount), + }) + .collect(), + difference: money(check.difference), + } + } + // No document of this kind has posted yet: nothing to tie out. + None => AgingControlOut { + account: String::new(), + balance: money(Decimal::ZERO), + documents_total: money(Decimal::ZERO), + unattached: Vec::new(), + difference: money(Decimal::ZERO), + }, + }; + Ok(Json(AgingOut { + as_of: as_of.to_string(), + rows, + documents, + control, + })) +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 7a4ef1b..68ca38c 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3091,17 +3091,73 @@ pub struct CreateDocumentOut { pub notes: Vec, } +/// One invoice or bill a payment applies to (plan P4b S6; B-42). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PaymentApplicationParam { + /// The invoice (books.receive_payment) or bill (books.pay_bill), by id. + #[serde(default)] + pub invoice: Option, + /// The bill (books.pay_bill), by id. + #[serde(default)] + pub bill: Option, + /// The cash paid against it, decimal string, positive. + pub amount: String, + /// An early-payment discount taken off it, decimal string. + #[serde(default)] + pub discount: Option, + /// Why a discount outside the document's terms was taken (a late + /// discount the vendor honours): one line, recorded with the payment. + #[serde(default)] + pub discount_reason: Option, +} + +/// A processor's fee withheld from a customer payment (plan P4b S6; A-4). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct FeeParam { + /// The fee, decimal string, positive. + pub amount: String, + /// The expense account it goes to (id or code); omitted: the + /// payment-processing-fees role's account. + #[serde(default)] + pub account: Option, + /// The processor, when the books know it as a vendor (id or name). + #[serde(default)] + pub vendor: Option, +} + /// Parameters for `books.receive_payment` / `books.pay_bill`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PaymentParams { - /// Invoice id (receive_payment) or bill id (pay_bill). - pub document_id: u64, + /// Invoice id (receive_payment) or bill id (pay_bill); the older name + /// of `invoice` / `bill`. + #[serde(default)] + pub document_id: Option, + /// The invoice, by id (books.receive_payment). + #[serde(default)] + pub invoice: Option, + /// The bill, by id (books.pay_bill). + #[serde(default)] + pub bill: Option, /// Payment date, YYYY-MM-DD. pub date: String, - /// Payment amount, decimal string. - pub amount: String, - /// Cash account id or code. - pub cash_account: String, + /// Payment amount, decimal string; with `invoice` or `bill` only. The + /// applications' amounts are the cash; a discount is beside it. + #[serde(default)] + pub amount: Option, + /// Cash account id or code; the older name of `deposit_to` / + /// `paid_from`. + #[serde(default)] + pub cash_account: Option, + /// The account the cash reaches (receive_payment): a bank or card that + /// holds no role. + #[serde(default)] + pub deposit_to: Option, + /// The account the cash leaves (pay_bill): a bank, card or payable + /// that holds no role. + #[serde(default)] + pub paid_from: Option, /// How the money moved: a payment method's id or label /// (`books.instruments`), tagged on the payment's entry in the same /// call (D-40: a card payment is left out of 1099-NEC). @@ -3112,6 +3168,34 @@ pub struct PaymentParams { /// carried in the entry's description (D-45). #[serde(default)] pub reference: Option, + /// Several invoices (receive_payment) or bills (pay_bill) settled by + /// one payment, one party. + #[serde(default)] + pub applications: Vec, + /// The processor's fee withheld from a customer payment (never on a + /// bill): receivables are credited the gross, the fee goes to expense, + /// the deposit account gets the net. + #[serde(default)] + pub fee: Option, + /// A check or note marked "paid in full" for a disputed amount: adds a + /// warning under UCC §3-311 and records nothing else. + #[serde(default)] + pub paid_in_full_marking: bool, +} + +/// One document a payment settled, after it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PaidDocumentOut { + /// "invoice:7" or "bill:12". + pub r#ref: String, + /// Its number, when it has one ("INV-0007"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub number: Option, + /// What it still owes after the payment, decimal string. + pub outstanding: String, + /// Its status after it: open, partial, paid, written_off, void, + /// overpaid, or unattached. + pub status: String, } /// Output of `books.receive_payment` / `books.pay_bill`. @@ -3119,12 +3203,22 @@ pub struct PaymentParams { pub struct PaymentOut { /// The payment journal entry's id. pub entry_id: u64, - /// What the document still owes after it, decimal string (derived - /// from its lines that stand). - pub remaining: String, - /// The document's status after it: open, partial, paid, written_off, - /// void, overpaid, or unattached. - pub status: String, + /// Every document it settled, in application order. + pub documents: Vec, + /// What reached (receive_payment) or left (pay_bill) the account: the + /// cash less a processor's fee, decimal string. + pub deposited: String, + /// What the single document still owes (the older shape; present when + /// one document was settled). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remaining: Option, + /// The single document's status (the older shape). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// What to tell the human (a "paid in full" marking's UCC §3-311 + /// warning). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } /// Parameters for `books.documents`. @@ -3361,6 +3455,10 @@ pub struct AgingParams { #[serde(default)] #[schemars(description = AS_OF_DOC)] pub as_of: Option, + /// Also one row per document, with its number, dates, terms and + /// bucket. + #[serde(default)] + pub detail: bool, } /// One row of an aging report. @@ -3382,6 +3480,53 @@ pub struct AgingRowOut { pub total: String, } +/// One document in an aging's detail. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AgingDocumentOut { + /// "invoice:7" or "bill:12". + pub r#ref: String, + /// Its number, when it has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub number: Option, + /// The customer or vendor. + pub party: String, + /// Its own date. + pub date: String, + /// When payment is due. + pub due_date: String, + /// Its terms, as they print ("2/10 net 30"), when it has them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terms: Option, + /// The sum of its lines. + pub total: String, + /// Cash paid that stands. + pub paid: String, + /// Discounts taken that stand. + pub discounted: String, + /// What it still owes as of the day. + pub outstanding: String, + /// current, days_30, days_60, days_90 or days_90_plus. + pub bucket: String, + /// Days past its due date while something is owed; 0 otherwise. + pub days_overdue: i64, +} + +/// The aging's account against its documents (the control check, B-23, +/// B-38 INVARIANT 1). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AgingControlOut { + /// "1200 Accounts Receivable" or "2000 Accounts Payable". + pub account: String, + /// Its ledger balance as of the day. + pub balance: String, + /// What its documents owe on it, together. + pub documents_total: String, + /// The lines on it that are no document's. + pub unattached: Vec, + /// balance − documents_total − unattached: always 0.00. + pub difference: String, +} + /// Output of `books.ar_aging` / `books.ap_aging`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AgingOut { @@ -3389,6 +3534,71 @@ pub struct AgingOut { pub as_of: String, /// Rows, one per party with an outstanding balance. pub rows: Vec, + /// One row per open document, when `detail` was asked for. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub documents: Vec, + /// The account's tie-out. + pub control: AgingControlOut, +} + +/// Parameters for `books.bills_due`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct BillsDueParams { + /// Show bills due on or before this day, YYYY-MM-DD (omitted: today). + #[serde(default)] + pub through: Option, + /// As of this day for what each bill owes ([`AS_OF_DOC`]). + #[serde(default)] + #[schemars(description = AS_OF_DOC)] + pub as_of: Option, +} + +/// One bill to pay, with its discount window when its terms give one. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DueBillOut { + /// "bill:12". + pub r#ref: String, + /// The vendor. + pub vendor: String, + /// The vendor's own invoice number, when recorded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vendor_ref: Option, + /// When payment is due. + pub due_date: String, + /// What it still owes as of the day. + pub outstanding: String, + /// The last day its early-payment discount is open, when it has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discount_by: Option, + /// The most its terms allow off, decimal string. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_discount: Option, + /// What to pay to take the discount: the outstanding less the + /// discount, decimal string. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pay_by_discount_date_amount: Option, +} + +/// One date's running total of what falls due through it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DueDateTotalOut { + /// The due date. + pub date: String, + /// What falls due on or before it, together. + pub amount: String, +} + +/// Output of `books.bills_due`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct BillsDueOut { + /// The day it is as of. + pub as_of: String, + /// The last day a bill is included for. + pub through: String, + /// Bills due by `through`, due date first. + pub bills: Vec, + /// The running total, one row per due date. + pub by_date: Vec, } // --------------------------------------------------------------------------- @@ -4719,7 +4929,7 @@ pub struct ReconciliationsOut { /// Parameters for `books.handbook`. #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct HandbookParams { - /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index. + /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, invoices-and-bills, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index. #[serde(default)] pub section: Option, } diff --git a/docs/architecture.md b/docs/architecture.md index 13ce3bb..0687a55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -147,8 +147,11 @@ each declared with `#[path]`, opening with `use super::*`, and holding a BooksServer`; `server.rs`'s `tool_router` adds every group's router. The groups are: -- `documents` — invoices, bills, payments, voids, `books.documents`, - `books.document`, the control check and the agings; +- `documents` — invoices, bills, voids, `books.documents`, + `books.document` and the control check; +- `settlements` — payments that settle one or several documents + (`books.receive_payment`, `books.pay_bill`), `books.bills_due`, and the + agings (`books.ar_aging`, `books.ap_aging`) with their tie-out (P4b S6); - `entries` — `books.draft_entry`, `books.add_line`, `books.post_entry`, `books.reverse_entry`, `books.get_entry`, `books.search_entries`, the bank CSV import; diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index d3734d5..315589c 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -1668,7 +1668,11 @@ posting. `journal`: each entry posted in the period, line by line; `iif`: every posted entry from the first through `as_of`, never a period), `tax_summary` (see "Tools — tax categories"), `fixed_asset_register` - (as of a day; see "Tools — fixed assets"). + (as of a day; see "Tools — fixed assets"), `ar_aging`, `ap_aging` + (as of a day: columns `number,party,date,due_date,terms,total,paid, + discounted,outstanding,bucket,days_overdue`, one row per open + document, then a tie-out line `TOTAL,,,,,,,,<Σ outstanding>,GL + ,difference 0.00`; B-23, 8.3.10). - Formats: `pdf` and `xlsx` for `profit_and_loss`, `balance_sheet`, `cash_flow`, `trial_balance`, `members_equity`, `tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for @@ -2008,21 +2012,37 @@ S4 changes `books.create_invoice` and `books.create_bill`; see plan 8.2 S4. never tagged; `notes` as for invoices, but none for an on-spec project (a cost of on-spec work is an expense like any other); the feature as for invoices. -- `books.receive_payment { document_id, date, amount, cash_account, - instrument?, reference? }` → `{ entry_id, remaining, status }`. Posts - Dr Cash / Cr A/R, the receivable line tagged with the invoice (B-38), - neither line tagged with a project (balance-sheet only: the invoice's - lines carry its projects). `instrument` (id or label) is tagged on the - entry in the same call; `reference` (one line, at most 200 characters: - a charge id, a trace number) goes into its description. `remaining` - and `status` (open, partial, paid, written_off, void, overpaid, - unattached) are the invoice's derived figures after it. The books - record payments; they never collect them (D-45). -- `books.pay_bill { document_id, date, amount, cash_account, instrument?, - reference? }` → `{ entry_id, remaining, status }`. Posts Dr A/P / Cr - Cash, the payable line tagged with the bill, neither line tagged with a - project; `instrument` and `reference` as for `receive_payment` (a card - payment is left out of 1099-NEC, D-40). +- `books.receive_payment { date, deposit_to | cash_account, instrument?, + reference?, invoice | document_id, amount? | applications: [{ invoice, + amount, discount?, discount_reason? }], fee?: { amount, account?, + vendor? }, paid_in_full_marking? }` → `{ entry_id, documents: [{ ref, + number?, outstanding, status }], deposited, remaining?, status?, + warnings? }`. Settles one or several invoices of one customer in one + entry (B-42): Dr the deposit account the cash less the fee, Dr the + payment-processing-fees account the fee, Dr sales allowances the + discounts, Cr A/R each invoice's cash and discount, every receivable + line tagged with its invoice (payment lines in application order, then + the discount lines). The `amount`s are cash only; a discount goes beside + one. A discount at most the invoice's terms allow and dated by their + discount date needs no reason; a larger or later one needs + `discount_reason`. `fee.vendor` (a processor the books know as a vendor) + is named in the entry's description; only cash movements count toward a + 1099, so the fee is not a payment to the processor. `deposited` is the + cash less the fee (A-4's gross presentation); `remaining` and `status` + repeat the single document's figures. An overpayment is refused with the + excess and the remedy (a plain entry to 2300 Customer Deposits). + `paid_in_full_marking: true` adds a UCC §3-311 warning and records + nothing else. `document_id` and `cash_account` are the older names of + `invoice`/`bill` and `deposit_to`/`paid_from`. The books record + payments; they never collect them (D-45). +- `books.pay_bill { date, paid_from | cash_account, instrument?, + reference?, bill | document_id, amount? | applications: [{ bill, amount, + discount?, discount_reason? }] }` → as `receive_payment`, per bill and + one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer + account the cash, Cr purchase discounts the discounts. A discount on a + bill with an asset or prepaid line is refused with its remedy (record + the bill net, or reclassify Dr 6995 / Cr the asset, D-49); a processor's + fee is refused on a bill. - `books.void_document { kind, id, date, reason }` → `{ entry_id, status }`. Voids an invoice or bill (`kind` `invoice` or `bill`) that should never have existed by reversing its own entry with the reason; it keeps its @@ -2060,9 +2080,22 @@ S4 changes `books.create_invoice` and `books.create_bill`; see plan 8.2 S4. there is no unattached line and nothing needing attention. `books.verify` checks the same identity and `books.digest` lists its problems (`control_problems`). -- `books.ar_aging { as_of? }` → `{ as_of, rows: [{ name, current, days_30, - days_60, days_90, days_90_plus, total }] }` (`as_of` omitted: today). -- `books.ap_aging { as_of? }` → same, per vendor (`as_of` omitted: today). +- `books.ar_aging { as_of?, detail? }` → `{ as_of, rows: [{ name, + current, days_30, days_60, days_90, days_90_plus, total }], documents?: + [{ ref, number?, party, date, due_date, terms?, total, paid, + discounted, outstanding, bucket, days_overdue }], control: { account, + balance, documents_total, unattached, difference } }` (`as_of` omitted: + today). `detail: true` adds one row per open invoice; `control` is the + receivables account's tie-out (B-23, B-38 INVARIANT 1), `difference` + always `0.00`. +- `books.ap_aging { as_of?, detail? }` → same, per vendor and bill. +- `books.bills_due { through?, as_of? }` → `{ as_of, through, bills: + [{ ref, vendor, vendor_ref?, due_date, outstanding, discount_by?, + max_discount?, pay_by_discount_date_amount? }], by_date: [{ date, + amount }] }`. Every open bill due on or before `through` (omitted: + today), due date first, with its terms' early-payment discount when it + is still open (`discount_by`, the most off, what to pay to take it); + `by_date` is the running total of what falls due through each date. ## Tools — foreign exchange diff --git a/docs/testing.md b/docs/testing.md index c1f814b..7303b5d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3050,3 +3050,80 @@ and `BooksServer::new`'s copy (a dropped field must fail `isolation_label_is_enforced_under_the_unit`); the host's `server_flags` extension and `parse_args`' passthrough of `--isolation` and its optional `--http` value. + +## P4b S6: paying on terms (2026-10-27) + +rules.md B-42; plan 8.2 P4b S6. One payment settles several documents of +one party, with the early-payment discounts their terms allow, the +processor's fee posted gross, a bills-due list, the agings' detail and +tie-out, and the aging exports. + +- **Core (`crates/ledger-core/tests/spec_documents.rs`):** + `bill_on_terms_with_discount` (the gross-method entry for a bill on + "2/10 net 30", and a 3% discount refused without a reason, taken with + one), `late_discount_needs_a_reason` (after the discount date, and the + asset-line refusal's unchanged books), + `discount_on_an_asset_bill_is_refused_with_the_remedy` (D-49: the code + and both remedies), `a_fee_posts_gross_to_receivables_and_net_to_the_bank` + (Dr bank 97, Dr 6060 3, Cr A/R 100), + `a_settlement_names_its_lines_and_refuses_a_document_twice` (control + lines in application order; one document twice refused). + `spec_cash_flow.rs::equipment_bill_payment_is_investing_cash` keeps + D-35 through a settlement. +- **Kernel:** `kernel_bridge.rs::settle_agrees`; Kani + `settle_is_exact` (a nonzero discount and a nonzero fee covered). +- **Store (`crates/ledger-store/tests/arap_standing.rs`):** + `a_settlement_names_its_lines_and_replays` (one entry, two invoices, a + discount and a fee; each event's `line`, `discount_line` and `fee_line` + point at the rows the plan posts; replay equals live); + `settlement_waits_for_documents_v2` (refused with the feature remedy and + no journal line, then accepted after the upgrade); + `legacy_payment_events_keep_the_positional_rule` (the old fixture's + events carry no `line`, and replay still tags in order). + `arap_props.rs`'s history gains `Settle` (two documents, a discount and, + on invoices, a fee), so `subledger_equals_control`, the bounds and + replay-equals-live run over settlements. +- **Store's older shapes:** `tests/store.rs::books_written_under_older_rules_still_open` + writes a payment event of S6's shape by hand through `JournalWriter` + and opens the books, the invoice reading paid and discounted. +- **Over the wire (`crates/ledger-cli/tests/documents.rs`):** + `one_payment_settles_two_invoices`, `late_discount_needs_a_reason`, + `a_fee_posts_gross_to_receivables_and_net_to_the_bank`, + `card_paid_bill_is_not_1099_nec`, + `multi_document_payment_counts_once_for_1099_and_party_totals`, + `overpayment_refused_with_the_excess`, + `bills_due_orders_by_due_and_discount` (the list and its running total; + no terms, so no discount fields), `the_aging_export_ties_out` (the CSV + header, row and `TOTAL` tie-out). +- **CPA package (`crates/ledger-package/tests/package.rs`):** + `the_aging_csvs_tie_to_the_control_check`. +- **Viewer (`crates/ledger-gui/tests/invoices.rs`):** + `the_list_shows_the_ledgers_figures` re-renders the Money out list's + screenshot with the new "Discount by" column (blank until S4.3 + journals terms). +- **Not yet possible through the MCP (depends on S4.3/S4.4):** + `bill_on_terms_with_discount` and `bills_due_orders_by_due_and_discount`'s + discount half need a journaled `terms` field (S4.3) and + `equipment_bill_payment_is_investing_cash` needs a bill line's account + in `books.create_bill` (S4.4). The core tests named for them stand in + until those land; see the unit's report. +- **Older books (8.0.6):** the pre-S1 fixture + `tests/fixtures/p4-arap-before` is also pre-S6 and holds payments; its + golden figures still hold (`legacy_reversal_now_counts`), and S6 moves + no figure. Its journal has no declared `documents_v2`, so the store test + above proves the refusal and the upgrade. + +**For P12 (mutation targets):** `kernel::settle`'s length check, each +negative check, the fee-above-cash test and both checked adds; +`Ledger::settlement_lines`'s kind/party checks, the duplicate-document +check, the per-application `cash + discount` bound, the discount window +(`<= max`, `<= by`, "has a reason"), the asset-bill loop, the fee-on-bill +refusal and the row order (payment lines, discounts, fee, cash); +`check_discount`'s `within` conjunction and its `because` arms; +`replay_*_payment_at`'s line/amount/account/side match and the discount +line's tag; the store's `documents_v2` condition (more than one +application, any discount, a fee) and the `line`/`discount_line`/`fee_line` +arithmetic; `bills_due`'s `through` filter, the discount-window test and +the running-total grouping; `aging_documents`'s outstanding filter and +bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the +viewer's `discount_by` and `days_overdue` flags. diff --git a/plan/served.md b/plan/served.md index 65948c9..df3c8d2 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1358,7 +1358,15 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `Ledger::settle` and its tests, the payment events' new fields and arms, `books.bills_due`, the aging exports; touches the 8.0.3 files, `crates/ledger-mcp/src/tools/documents.rs` (after S4.4; coordinate with the S4 engineer on `tools/documents.rs`: S6 adds its handlers in a second file `tools/settlements.rs` if S4.4 is still open). 6. **Tests and proofs:** named above; Kani `settle_is_exact`; Lean: none new (the posting step is `Step.document` with more lines; `control_agrees` covers it because every control line is tagged). 7. **Done when:** (1) the tests pass; (2) `several_payment_events_on_one_entry_tag_its_lines_in_order` still passes (the legacy rule); (3) the 1099 worksheet and party totals count a two-invoice payment once (`multi_document_payment_counts_once_for_1099_and_party_totals`); (4) the CPA package's aging CSVs tie to `control_check` on the demo books; (5) 8.0.6 in the PR, no golden figure moved; (6) `just check`, `just prove` green. -8. **As built:** none yet. +8. **As built** (2026-10-27, `S6`): the steps above are built except the departures below. + - **The handlers live in `crates/ledger-mcp/src/tools/settlements.rs`**, not `tools/documents.rs` (the EM approved this group, because S4.4 works in `documents.rs`). `books.receive_payment`, `books.pay_bill`, `books.ar_aging` and `books.ap_aging` moved there; `books.bills_due` is new. `docs/architecture.md` names the group. + - **`Ledger::settle` is a public command**, as Build 2 asks, so `Store::settle` calls it; its posting is still `Step.document` (every control line carries its document), and Lean gained `Documents.settle_posts_a_document_step` and `Documents.settle_unattached` instead of a new `Step` constructor (plan 8.2 S6, Tests and proofs: "the posting step is `Step.document` with more lines"). + - **Terms cannot yet be journaled on a document** (S4.3 owns `invoice.created`/`bill.created`'s `terms`). `Ledger::settle` reads `doc.terms` wherever it is set, and the core tests (`bill_on_terms_with_discount`, `late_discount_needs_a_reason`) build documents through `create_invoice_with`/`create_bill_with`; the over-the-wire `bill_on_terms_with_discount` and the discount half of `bills_due_orders_by_due_and_discount` wait for S4.3. A bill line's account is likewise S4.4's MCP field, so `equipment_bill_payment_is_investing_cash` is a core test (`spec_cash_flow.rs`) until then. + - **A fee's processor is recorded in the entry's description, not as the entry's vendor tag.** Ledger party/vendor tags are entry-wide, not line-level; tagging the entry with the processor would move the customer's party total and muddy 1099-NEC. `fee.vendor` is resolved and named in the description, and only cash movements count toward a 1099. + - **`confirmed` is not added**: P4 owns preview/confirm for every mutating tool, and adding a preview now would duplicate that sweep. The older `document_id`/`cash_account` names still work beside `invoice`/`bill` and `deposit_to`/`paid_from`. + - **No schema change**: discounts and fees are tagged ledger lines and payment-event fields, so the projection needs no column and schema 18 is not taken. + - **No bookspec examples yet**: `payment-terms-early-discount` needs S4.3's journaled terms; `arap-settlement-exact` and `processor-fee-gross-presentation` are recorded here as the next step for the bookspec unit, not written. + - **8.0.6:** item 1 is in `books_written_under_older_rules_still_open`; item 2 is the pre-S1 (therefore pre-S6) fixture `tests/fixtures/p4-arap-before`, whose figures still hold; item 3 is `settlement_waits_for_documents_v2`; item 4 is `tests/format.rs` unchanged (no new event type) plus `format_upgrade.rs` unchanged (the `documents_v2` setting is S4.2's); item 5 is the same fixture's `figures.json` and `legacy_reversal_now_counts` (no figure moved); item 6 is in the PR/report. #### P4b S7: bad debts (`write_off_invoice`) From 6c4480cdc8593123af6256e9a35184f6cdac99dc Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 10:34:43 -0600 Subject: [PATCH 4/4] feat(package): aging CSVs in the export and the CPA package (P4b S6) The CPA's first tie-out is that the aging equals the control account, so books.export and the CPA package now write ar_aging.csv and ap_aging.csv from one builder: one row per open document (number, party, dates, terms, total, paid, discounted, outstanding, bucket, days overdue) and a TOTAL line carrying the outstanding sum, the GL account and balance, and the difference (always 0.00; B-23, B-38 INVARIANT 1). XLSX and PDF lay the same aging out as a statement with the tie-out beneath it. The package README states the tie-out, the gross discount method (A-8) and the direct write-off method (A-5). The chart templates gain 6995 Purchase Discounts so no template puts another account at a control role's code (4100 and 6060 already match). The viewer's Money out list gains the discount-deadline column and colours a due-soon or discount-open date; the screenshot is re-rendered. Test: the_aging_csvs_tie_to_the_control_check in ledger-package; the existing package file-order test and mcp_journeys see the two new files. --- crates/ledger-gui/src/pages/invoices.rs | 97 ++++++----- crates/ledger-gui/tests/invoices.rs | 2 + crates/ledger-import/src/templates.rs | 6 + crates/ledger-package/src/aging.rs | 216 ++++++++++++++++++++++++ crates/ledger-package/src/lib.rs | 2 + crates/ledger-package/src/package.rs | 13 +- crates/ledger-package/tests/package.rs | 81 +++++++++ docs/parity.md | 12 ++ 8 files changed, 390 insertions(+), 39 deletions(-) create mode 100644 crates/ledger-package/src/aging.rs diff --git a/crates/ledger-gui/src/pages/invoices.rs b/crates/ledger-gui/src/pages/invoices.rs index 6e903a9..64dfe2f 100644 --- a/crates/ledger-gui/src/pages/invoices.rs +++ b/crates/ledger-gui/src/pages/invoices.rs @@ -55,6 +55,7 @@ struct Item { party: String, date: NaiveDate, due: NaiveDate, + terms: Option, state: Result, ledger_core::LedgerError>, } @@ -74,6 +75,7 @@ fn items(b: &Books, bills: bool) -> Vec { .unwrap_or_default(), date: d.date, due: d.due_date, + terms: d.terms.clone(), state: b.doc_today(DocRef::Bill(d.id)), }) .collect() @@ -89,6 +91,7 @@ fn items(b: &Books, bills: bool) -> Vec { .unwrap_or_default(), date: d.date, due: d.due_date, + terms: d.terms.clone(), state: b.doc_today(DocRef::Invoice(d.id)), }) .collect() @@ -282,26 +285,23 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) { return; } let amt = widgets::AMOUNT_MIN + 16.0; - let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0; + let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0 + if bills { 104.0 } else { 0.0 }; let w = ui.available_width().max(fixed + 160.0); widgets::min_width(ui, "invoice_list", w, |ui| { let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); - row( - ui, - w, - &[ - head("Document", 110.0, false), - head(if bills { "Vendor" } else { "Customer" }, 0.0, false), - head("Date", 104.0, false), - head("Due", 104.0, false), - head("Status", 120.0, false), - head("Total", amt, true), - head("Still owed", amt, true), - ], - None, - true, - false, - ); + let mut header = vec![ + head("Document", 110.0, false), + head(if bills { "Vendor" } else { "Customer" }, 0.0, false), + head("Date", 104.0, false), + head("Due", 104.0, false), + head("Status", 120.0, false), + head("Total", amt, true), + head("Still owed", amt, true), + ]; + if bills { + header.push(head("Discount by", 104.0, false)); + } + row(ui, w, &header, None, true, false); widgets::rule(ui); for it in rows { let (status, total, owed, late) = match &it.state { @@ -314,28 +314,49 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) { Ok(None) => ("Unknown".into(), String::new(), String::new(), false), Err(_) => ("Can't compute".into(), String::new(), String::new(), false), }; + let discount_by = it.terms.as_ref().and_then(|t| t.discount_by(it.date)); + let discount_open = bills && discount_by.is_some_and(|d| d >= b.today); + let discount_soon = discount_open + && discount_by.is_some_and(|d| d <= b.today + chrono::Days::new(3)); + let due_soon = bills + && matches!(&it.state, Ok(Some(s)) + if !s.outstanding.is_zero() && s.status != DocStatus::Void) + && it.due <= b.today + chrono::Days::new(7); let name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed); - let resp = row( - ui, - w, - &[ - (it.name.clone(), 110.0, false, p.text), - (it.party.clone(), 0.0, false, p.text), - (fmt::date(it.date), 104.0, false, p.text), - ( - fmt::date(it.due), - 104.0, - false, - if late { p.warning } else { p.text }, - ), - (status, 120.0, false, if late { p.warning } else { p.muted }), - (total, amt, true, p.text), - (owed, amt, true, p.text), - ], - None, - false, - true, - ); + let mut cells = vec![ + (it.name.clone(), 110.0, false, p.text), + (it.party.clone(), 0.0, false, p.text), + (fmt::date(it.date), 104.0, false, p.text), + ( + fmt::date(it.due), + 104.0, + false, + if late || due_soon { p.warning } else { p.text }, + ), + ( + status.clone(), + 120.0, + false, + if late { p.warning } else { p.muted }, + ), + (total, amt, true, p.text), + (owed, amt, true, p.text), + ]; + if bills { + cells.push(( + discount_by.map(fmt::date).unwrap_or_default(), + 104.0, + false, + if discount_soon { + p.warning + } else if discount_open { + p.text + } else { + p.muted + }, + )); + } + let resp = row(ui, w, &cells, None, false, true); // Named in full, for assistive tech (and the tests). resp.widget_info(|| { egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name) diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index f51ad3c..b6862b4 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -156,6 +156,8 @@ fn the_list_shows_the_ledgers_figures() { ); let mut h = open(&dir, Route::Payables); shot(&mut h, "payables"); + // The Money out list carries the discount-deadline column (blank + // until terms are journaled, S4.3). assert!( h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00") .is_some() diff --git a/crates/ledger-import/src/templates.rs b/crates/ledger-import/src/templates.rs index fb396fb..3f0ead7 100644 --- a/crates/ledger-import/src/templates.rs +++ b/crates/ledger-import/src/templates.rs @@ -307,6 +307,12 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result,GL ,difference `. +pub fn aging_csv( + ledger: &Ledger, + invoices: bool, + as_of: chrono::NaiveDate, +) -> Result { + let docs = ledger + .aging_documents(as_of, invoices) + .map_err(|e| e.to_string())?; + let mut out = String::from(AGING_CSV_HEADER); + out.push('\n'); + let mut total = Decimal::ZERO; + for d in &docs { + total += d.outstanding; + out.push_str(&format!( + "{},{},{},{},{},{},{},{},{},{},{}\n", + csv_escape(&number(ledger, d.doc)), + csv_escape(&d.party), + d.date, + d.due_date, + csv_escape(d.terms.as_deref().unwrap_or("")), + money(d.total), + money(d.paid), + money(d.discounted), + money(d.outstanding), + d.bucket, + d.days_overdue, + )); + } + let (code, balance, difference) = tie_out(ledger, invoices, as_of)?; + let account = if code.is_empty() { + "GL —".to_string() + } else { + format!("GL {code}") + }; + out.push_str(&format!( + "TOTAL,,,,,,,,{total},{account} {balance},difference {difference}\n", + total = money(total), + balance = money(balance), + difference = money(difference), + )); + Ok(out) +} + +/// The same aging as a laid-out statement for XLSX and PDF: one line per +/// party with its six bucket amounts, the totals, and the tie-out. +pub fn aging_statement( + ledger: &Ledger, + invoices: bool, + as_of: chrono::NaiveDate, +) -> Result { + use ledger_import::report::{Row, RowKind, Statement}; + let party_rows = if invoices { + ledger.ar_aging(as_of) + } else { + ledger.ap_aging(as_of) + } + .map_err(|e| e.to_string())?; + let (code, balance, difference) = tie_out(ledger, invoices, as_of)?; + let columns: Vec = ["Current", "1-30", "31-60", "61-90", "90+", "Total"] + .into_iter() + .map(str::to_string) + .collect(); + let mut out_rows: Vec = vec![Row { + kind: RowKind::Section, + label: if invoices { + "Receivables".into() + } else { + "Payables".into() + }, + amounts: vec![], + currency: false, + rule_above: false, + }]; + let (mut current, mut d30, mut d60, mut d90, mut d90p) = ( + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + Decimal::ZERO, + ); + for r in &party_rows { + let total = r.total().map_err(|e| e.to_string())?; + current += r.current; + d30 += r.days_30; + d60 += r.days_60; + d90 += r.days_90; + d90p += r.days_90_plus; + out_rows.push(Row { + kind: RowKind::Line, + label: r.name.clone(), + amounts: vec![ + Some(r.current), + Some(r.days_30), + Some(r.days_60), + Some(r.days_90), + Some(r.days_90_plus), + Some(total), + ], + currency: false, + rule_above: false, + }); + } + let total = current + d30 + d60 + d90 + d90p; + out_rows.push(Row { + kind: RowKind::Total, + label: "Total".into(), + amounts: vec![ + Some(current), + Some(d30), + Some(d60), + Some(d90), + Some(d90p), + Some(total), + ], + currency: true, + rule_above: true, + }); + out_rows.push(Row { + kind: RowKind::Spacer, + label: String::new(), + amounts: vec![], + currency: false, + rule_above: false, + }); + out_rows.push(Row { + kind: RowKind::Section, + label: "Tie-out".into(), + amounts: vec![], + currency: false, + rule_above: false, + }); + out_rows.push(Row { + kind: RowKind::Line, + label: if code.is_empty() { + "GL balance".to_string() + } else { + format!("GL {code} balance") + }, + amounts: vec![None, None, None, None, None, Some(balance)], + currency: false, + rule_above: false, + }); + out_rows.push(Row { + kind: RowKind::Total, + label: "Difference".into(), + amounts: vec![None, None, None, None, None, Some(difference)], + currency: true, + rule_above: true, + }); + Ok(Statement { + // Excel sheet names take no slash; the statement's title is the + // one both XLSX and PDF draw. + title: if invoices { + "AR Aging".into() + } else { + "AP Aging".into() + }, + period: format!("As of {}", as_of.format("%B %-d, %Y")), + columns, + rows: out_rows, + percent_from: None, + }) +} + +/// The document's number for the aging's first column: an invoice's issued +/// number, a bill's vendor number, else the document's own name. +fn number(ledger: &Ledger, doc: DocRef) -> String { + match doc { + DocRef::Invoice(id) => ledger + .get_invoice(id) + .and_then(|i| i.number.clone()) + .unwrap_or_else(|| format!("Invoice {id}")), + DocRef::Bill(id) => ledger + .get_bill(id) + .and_then(|b| b.vendor_ref.clone()) + .unwrap_or_else(|| format!("Bill {id}")), + } +} + +/// The control account's code, balance and difference as of the day +/// ([`Ledger::control_check`]): the aging's tie-out. +fn tie_out( + ledger: &Ledger, + invoices: bool, + as_of: chrono::NaiveDate, +) -> Result<(String, Decimal, Decimal), String> { + let role = if invoices { "receivables" } else { "payables" }; + let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?; + let Some(check) = checks.iter().find(|c| c.role == role) else { + return Ok((String::new(), Decimal::ZERO, Decimal::ZERO)); + }; + let code = ledger + .chart() + .get(check.account) + .map(|a| a.code.clone()) + .unwrap_or_default(); + Ok((code, check.balance, check.difference)) +} diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs index 578f6cd..b6bffc0 100644 --- a/crates/ledger-package/src/lib.rs +++ b/crates/ledger-package/src/lib.rs @@ -23,6 +23,7 @@ //! whichever [`PackageBooks`] they are read through. //! ``` +mod aging; mod attention; mod books; mod iif; @@ -34,6 +35,7 @@ mod text; mod tins; mod vendor_1099; +pub use aging::{AGING_CSV_HEADER, aging_csv, aging_statement}; pub use attention::{ Attention, Integrity, Report, Severity, Target, files_1099, find, find_with, reconcile_prompt, w9_missing, w9_prompt, diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index 38f2000..2ec0ea6 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -372,6 +372,17 @@ pub fn build_package( files.push((format!("{name}.pdf"), pdf(st)?)); files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?)); } + // The receivables and payables agings, each with its tie-out line to + // the control account (B-23, 8.3.10): the same CSV books.export and + // the viewer make. + files.push(( + "ar_aging.csv".into(), + crate::aging::aging_csv(ledger, true, as_of)?.into_bytes(), + )); + files.push(( + "ap_aging.csv".into(), + crate::aging::aging_csv(ledger, false, as_of)?.into_bytes(), + )); // The fixed asset register, when an asset was acquired by as_of (one // acquired later is not in it, so neither is an empty register). if ledger @@ -566,7 +577,7 @@ pub fn build_package( let year_end = NaiveDate::from_ymd_opt(year, 12, 31) == Some(as_of); let include_tins = tins.is_some(); let readme = format!( - "{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\n{iif}{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n", + "{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\nAgings (CSV): ar_aging.csv and ap_aging.csv, one row per open invoice or bill, each ending with a TOTAL line that ties to its control account (B-23). Early-payment discounts are taken by the gross method (A-8); bad debts are written off directly (A-5).\n{iif}{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n", name = company.name, basis = company.basis, members = if company.members.is_empty() { diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index 7163246..78d85ec 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -338,6 +338,8 @@ fn a_single_member_package_holds_the_documented_files_in_order() { "tax-summary.xlsx".into(), "trial-balance.pdf".into(), "trial-balance.xlsx".into(), + "ar_aging.csv".into(), + "ap_aging.csv".into(), "general-ledger.xlsx".into(), "general-journal.xlsx".into(), "1099-worksheet-2026.csv".into(), @@ -2446,3 +2448,82 @@ fn the_k1_worksheet_reports_tax_basis_capital() { // The book worksheet is the same as without assets' bases. assert!(text(&p, "k1-members.csv").contains(",1685.00\n")); } + +/// The CPA package's aging CSVs are the agings, one row per open document +/// and a tie-out line that equals the control account (plan P4b S6, +/// 8.3.10; rules.md B-23, B-38 INVARIANT 1). +#[test] +fn the_aging_csvs_tie_to_the_control_check() { + let dir = tempfile::Builder::new() + .prefix("wb-package-aging-") + .tempdir() + .unwrap(); + let mut s = Store::create(&dir.path().join("books-data"), Currency::USD).unwrap(); + for (code, name, ty) in [ + ("1000", "Checking", AccountType::Asset), + ("3000", "Capital", AccountType::Equity), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + let bank = id_of(&s, "1000"); + let customer = s.add_customer("Fernhill Labs").unwrap(); + let vendor = s.add_vendor("Tobin Harlow Supply").unwrap(); + let line = |what: &str, cents: i64| DocumentLine { + description: what.into(), + amount: Decimal::new(cents, 2), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }; + let invoice = s + .create_invoice( + customer, + d("2026-02-01"), + d("2026-03-01"), + vec![line("Consulting", 50_000)], + ) + .unwrap(); + let bill = s + .create_bill( + vendor, + d("2026-02-02"), + d("2026-03-04"), + vec![line("Paper", 20_000)], + ) + .unwrap(); + s.receive_payment(invoice, d("2026-02-10"), Decimal::new(20_000, 2), bank) + .unwrap(); + s.pay_bill(bill, d("2026-02-11"), Decimal::new(10_000, 2), bank) + .unwrap(); + let p = build_package( + &s, + &settings(&company(false), &cash()), + &request(false, None), + ) + .unwrap(); + for (name, role, code, balance) in [ + ("ar_aging.csv", "receivables", "1200", "300.00"), + ("ap_aging.csv", "payables", "2000", "100.00"), + ] { + let csv = text(&p, name); + let total = csv.lines().last().unwrap(); + assert!( + total.starts_with("TOTAL,,,,,,,,300.00") || total.starts_with("TOTAL,,,,,,,,100.00") + ); + assert!( + total.contains(&format!("GL {code} {balance}")), + "{name}: {total}" + ); + assert!(total.ends_with("difference 0.00"), "{name}: {total}"); + let check = s + .ledger() + .control_check(d("2026-12-31")) + .unwrap() + .into_iter() + .find(|c| c.role == role) + .unwrap(); + assert_eq!(check.balance.to_string(), balance, "{name}"); + assert_eq!(check.difference, Decimal::ZERO, "{name}"); + } +} diff --git a/docs/parity.md b/docs/parity.md index e27be0b..32c9d3c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -45,6 +45,9 @@ No other statuses. | 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet | | Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet | | Automated cross-implementation diff harness (GnuCash first) | — (our `xtask oracle`) | todo | roadmap phase 3 | +| Multi-document settlements with early-payment discounts (gross method) | Business → Invoices/Bills; Receive payment | done | P4b S6: one entry, one party, one application per document (`kernel::settle`, B-42); a discount beyond the terms needs a reason; a bill with an asset line is refused (D-49); the bill and its payment tested, replay equal to live | +| A processor's fee on a payment received (gross presentation) | Xero/QBO Stripe & Shopify deposits | done | P4b S6: Dr the deposit the net, Dr processing fees the fee, Cr A/R the gross (A-4); `a_fee_posts_gross_to_receivables_and_net_to_the_bank` | +| Bills due, aging detail and tie-out, aging exports | Business → Bills; Reports → A/R and A/P Aging | done | P4b S6: `books.bills_due`, `detail`, `control`, `ar_aging.csv`/`ap_aging.csv` in `books.export` and the CPA package, each with its `TOTAL` tie-out (`the_aging_csvs_tie_to_the_control_check`) | Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the egui phase starts; semantics come first. @@ -104,3 +107,12 @@ door on loopback for Caddy or a tunnel. `crates/ledger-cli/tests/two_user.rs` proves the account boundary and the move's head in CI (`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`. The cutover rule in `docs/capability-parity.md` is unchanged. + +### P4b S6 paying on terms (2026-10-27) + +S6 adds the three rows above: multi-document settlements with +early-payment discounts (the gross method, A-8), the processor's fee +posted gross (A-4), and the bills-due list with the agings' detail, +tie-out and exports. The pre-S6 fixture's figures still hold (no figure +moves), and every new write needs the journal feature `documents_v2`, +which the owner's books declare at their next upgrade (D-52).