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]
+21
View File
@@ -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::<Vec<_>>(),
&discounts.iter().map(|&c| dec(c)).collect::<Vec<_>>(),
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.
@@ -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"));
}
+377
View File
@@ -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<String>| 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<String>| 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));
}