merge: P4b S6, paying on terms (branch S6, 6c4480cd)

The third of the paused branches: Ledger::settle with discounts and the
processor's fee (kernel::settle, Kani settle_is_exact, Lean
settle_posts_a_document_step), books.receive_payment and books.pay_bill
with applications, books.bills_due, the agings' detail and tie-out, the
aging exports and the CPA package's aging CSVs, the handlers in
tools/settlements.rs. Twelve files conflicted with the S4.4 and S5
merges, resolved as plan 8.0.2 says: both routers; both test appends
(the shared match arm in arap_standing.rs spliced into both tests; the
shared `.unwrap()` in store.rs); the scope table at 124; one
invoices-and-bills handbook (S5's send step 1 with S6's steps 2, 3, 5
and procedures) registered once; docs/mcp-interface.md with S4.4's
create docs and S6's payment docs; S5's law renumbered 35 and S6's 36
(S1's is 34); the parity rows and sections of both. S6's wire tests
create their bills as S4.4's contract now requires (confirmed, the
vendor's number, a line account, the vendor's invoice uploaded) and live
in their own module beside S4.4's tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:26:40 -06:00
commit be58059830
45 files changed
+3949 -520

No files matched your search

+112 -13
View File
@@ -425,6 +425,64 @@ impl PaymentDetails {
}
}
/// One document a settlement applies to (plan P4b S6; rules.md B-42): the
/// cash paid on it, the early-payment discount taken off it, and why a
/// discount outside its terms was taken anyway.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Application {
/// The invoice or bill it applies to.
pub doc: DocRef,
/// The cash paid against it, positive.
pub cash: Decimal,
/// The early-payment discount taken, zero when none.
pub discount: Decimal,
/// Why a discount outside the document's terms was taken (a vendor who
/// honours a late discount): one line, journaled with the payment.
pub discount_reason: Option<String>,
}
/// A processor's fee withheld from a customer payment (plan P4b S6; A-4):
/// receivables are credited the gross, the fee goes to its expense
/// account, and the deposit account gets the net.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Fee {
/// The fee, positive.
pub amount: Decimal,
/// The expense account it goes to; `None` is the
/// `payment_processing_fees` role's account.
pub account: Option<crate::AccountId>,
/// The processor, when the books know it as a vendor.
pub vendor: Option<VendorId>,
}
/// Everything one settlement needs (plan P4b S6; rules.md B-42): its date,
/// description, deposit account, the party every document belongs to, the
/// applications, and the processor's fee when one was withheld.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SettleParams {
/// The settlement's date.
pub date: NaiveDate,
/// The entry's description.
pub description: String,
/// The account the cash reaches (or leaves): a bank, a card, a payable
/// holding no role.
pub cash_account: crate::AccountId,
/// The customer or vendor every application names.
pub party: u64,
/// One or more documents, one party, one kind.
pub applications: Vec<Application>,
/// The processor's fee, on a customer payment only.
pub fee: Option<Fee>,
/// The payment method, when known (D-40).
pub instrument: Option<crate::InstrumentId>,
/// The processor's or bank's reference, when there is one.
pub reference: Option<String>,
}
/// One posted line a settlement's plan asks for, with the document it tags
/// when it is a control line ([`Application`]'s payment or discount).
pub type SettlementRow = (crate::AccountId, crate::Side, Decimal, Option<DocTag>);
/// How a document's payment terms count their days (rules.md B-39; plan
/// P4b S4.1): from the document's date, from the end of its month, or a
/// date the parties agreed.
@@ -1264,6 +1322,35 @@ impl AgingRow {
}
}
/// One document in an aging's detail: its figures and the bucket it falls
/// in (plan P4b S6, 8.3.10). Every figure is derived from its tagged lines
/// ([`crate::Ledger::doc_state`]).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AgingDocument {
/// The invoice or bill.
pub doc: DocRef,
/// The customer or vendor.
pub party: String,
/// Its own date.
pub date: NaiveDate,
/// When payment is due.
pub due_date: NaiveDate,
/// Its terms, as they print ("2/10 net 30"), when it has them.
pub terms: Option<String>,
/// The sum of its lines.
pub total: Decimal,
/// Cash paid that stands.
pub paid: Decimal,
/// Discounts taken that stand.
pub discounted: Decimal,
/// What it still owes as of the day.
pub outstanding: Decimal,
/// `current`, `days_30`, `days_60`, `days_90` or `days_90_plus`.
pub bucket: &'static str,
/// Days past its due date while something is owed; 0 otherwise.
pub days_overdue: i64,
}
/// Put an open balance in its aging bucket.
///
/// ```text
@@ -1296,23 +1383,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::*;
+72 -1
View File
@@ -676,9 +676,80 @@ pub enum LedgerError {
InvalidPaymentAmount(Decimal),
/// Payment exceeds the remaining balance.
#[error("payment of {0} exceeds remaining balance of {1}")]
#[error(
"payment of {0} exceeds remaining balance of {1}; the difference is the excess: record it as a plain entry to a customer-deposits liability (2300) and apply it later"
)]
Overpayment(Decimal, Decimal),
/// A settlement named no documents.
#[error("a settlement needs at least one invoice or bill")]
SettlementEmpty,
/// A settlement's documents aren't all invoices or all bills.
#[error("a settlement cannot mix invoices and bills: {0} is not the same kind as the others")]
SettlementMixedKinds(String),
/// A settlement's documents don't all belong to the one party it
/// names (rules.md B-42 INVARIANT 1).
#[error("{0} is not {1}'s: a settlement covers one party only")]
SettlementPartiesDiffer(String, u64),
/// A discount needs a reason: none is stated in the document's terms,
/// or it is larger than its terms allow, or it is taken after the
/// discount date (rules.md B-42 INVARIANT 2).
#[error(
"{doc}: the discount of {discount} needs a reason ({because}); pass discount_reason with the payment"
)]
DiscountNeedsReason {
/// The document.
doc: String,
/// The discount taken.
discount: Decimal,
/// Why the terms don't allow it as a matter of course.
because: String,
},
/// A discount on a bill whose lines include an asset or prepaid
/// account is refused: apportioning it across those lines is deferred
/// (D-49).
#[error(
"{doc} has a line on {code} \"{name}\" (an asset or prepaid account), so a discount on it cannot be apportioned (D-49): record the bill net of the discount, or reclassify by an entry Dr 6995 Purchase Discounts / Cr {code}"
)]
DiscountOnAssetBill {
/// The bill.
doc: String,
/// The asset line's account code.
code: String,
/// Its name.
name: String,
},
/// A settlement names the same document twice: each document takes one
/// application (its cash and its discount), so its bound is one bound.
#[error("{0} appears twice in one settlement: name it once, with all its cash and discount")]
SettlementDuplicateDocument(String),
/// A settlement's discount reason is not one line of at most
/// [`crate::arap::MAX_REFERENCE_CHARS`] characters.
#[error("a discount's reason is one line of at most {0} characters")]
InvalidDiscountReason(usize),
/// A processor's fee is a customer payment's only: a bill is paid from
/// the company's own money, with no processor taking a cut.
#[error("a processor's fee belongs on a customer payment, not on paying a bill")]
FeeOnBill,
/// A fee's amount or account the books can't take: the message says
/// why.
#[error("{0}")]
InvalidFee(String),
/// A settlement's figures can't be exact: the cash and discounts
/// don't cover the applications, or the fee leaves a negative deposit
/// (rules.md B-42 INVARIANT 1).
#[error("{0}")]
InvalidSettlement(String),
/// A payment's details were refused (its reference): the message says
/// why.
#[error("{0}")]
+57
View File
@@ -269,6 +269,63 @@ pub fn discount_max<T: Amount>(total: T, percent_bp: u32) -> Option<T> {
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<T> {
/// Σ 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<T: Amount>(cash: &[T], discounts: &[T], fee: T) -> Option<Settlement<T>> {
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.
///
+648 -117
View File
@@ -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",
@@ -6223,6 +6251,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<EntryId, LedgerError> {
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<Vec<arap::SettlementRow>, 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 &params.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 &params.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 &params.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<arap::SettlementRow> =
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 &params.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) = &params.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) = &params.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<EntryId, LedgerError> {
self.ensure_settlement_accounts(&params)?;
let rows = self.settlement_lines(&params)?;
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 &params.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).
@@ -6276,14 +6713,25 @@ impl Ledger {
cash_account: AccountId,
details: &arap::PaymentDetails,
) -> Result<EntryId, LedgerError> {
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(),
})
}
@@ -6335,100 +6783,28 @@ impl Ledger {
cash_account: AccountId,
details: &arap::PaymentDetails,
) -> Result<EntryId, LedgerError> {
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<EntryId, LedgerError> {
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
@@ -6741,6 +7117,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
@@ -6917,6 +7322,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<usize>,
discount: Decimal,
discount_line: Option<usize>,
) -> Result<(), LedgerError> {
let customer = self
.invoices
@@ -6924,15 +7347,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);
@@ -6947,6 +7385,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<usize>,
discount: Decimal,
discount_line: Option<usize>,
) -> Result<(), LedgerError> {
let vendor = self
.bills
@@ -6954,15 +7407,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);
@@ -7044,6 +7512,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<Vec<arap::AgingDocument>, 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
// ------------------------------------------------------------------
+8 -6
View File
@@ -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};
+32
View File
@@ -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]