feat(arap): settle documents with discounts and a fee (P4b S6)
One payment had to name one document and could take no discount or processor fee, so paying several invoices from one deposit meant several entries and the fee had nowhere to go. This adds the kernel's exact settlement arithmetic (applications total, net deposit), a Ledger::settle that posts one entry for a customer's or vendor's documents, and the gross method and gross fee A-8/A-4 chose. - kernel::settle: sum(cash)+sum(discounts)=applications, sum(cash)-fee= deposit, None on overflow or a negative deposit. Kani settle_is_exact plus the Decimal bridge. - Ledger::settle / settlement_lines: one party and one kind per settlement, one application per document, each cash+discount within payment_room; a discount within the terms (discount_max and discount_by) needs no reason, a larger or later one does; a bill with an asset line is refused with D-49's remedy; a fee is a customer payment's only and posts gross. - Rows are payment lines in application order, then discount lines, the fee line and the cash line, so a journaled event's line indices are stable; replay tags exactly the line an event names and leaves an unmatched line unattached (never refuses). - receive_payment_with and pay_bill_with become one-application calls. - Roles: sales_allowances (4100), purchase_discounts (6995) and payment_processing_fees (6060), constants used on demand but not added to control_accounts() (existing charts may use those codes for other things). - Lean: settle posts through Step.document, so settle_posts_a_document_step and settle_unattached, no new constructor; both in Audit.lean. - Core tests: bill_on_terms_with_discount, late_discount_needs_a_reason, discount_on_an_asset_bill_is_refused_with_the_remedy, a_fee_posts_gross_to_receivables_and_net_to_the_bank, a_settlement_names_its_lines_and_refuses_a_document_twice, and equipment_bill_payment_is_investing_cash in the cash-flow suite. - Bookspec B-42 is stated, with its id BS-arap-6; the catalogue and index now record it as CONVENTION. just check and just prove green.
This commit is contained in:
1 parent
ed637f0e27
commit
10ee0681c1
15 files changed
+1511
-146
No files matched your search
+112
-13
@@ -425,6 +425,64 @@ impl PaymentDetails {
|
||||
}
|
||||
}
|
||||
|
||||
/// One document a settlement applies to (plan P4b S6; rules.md B-42): the
|
||||
/// cash paid on it, the early-payment discount taken off it, and why a
|
||||
/// discount outside its terms was taken anyway.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Application {
|
||||
/// The invoice or bill it applies to.
|
||||
pub doc: DocRef,
|
||||
/// The cash paid against it, positive.
|
||||
pub cash: Decimal,
|
||||
/// The early-payment discount taken, zero when none.
|
||||
pub discount: Decimal,
|
||||
/// Why a discount outside the document's terms was taken (a vendor who
|
||||
/// honours a late discount): one line, journaled with the payment.
|
||||
pub discount_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// A processor's fee withheld from a customer payment (plan P4b S6; A-4):
|
||||
/// receivables are credited the gross, the fee goes to its expense
|
||||
/// account, and the deposit account gets the net.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Fee {
|
||||
/// The fee, positive.
|
||||
pub amount: Decimal,
|
||||
/// The expense account it goes to; `None` is the
|
||||
/// `payment_processing_fees` role's account.
|
||||
pub account: Option<crate::AccountId>,
|
||||
/// The processor, when the books know it as a vendor.
|
||||
pub vendor: Option<VendorId>,
|
||||
}
|
||||
|
||||
/// Everything one settlement needs (plan P4b S6; rules.md B-42): its date,
|
||||
/// description, deposit account, the party every document belongs to, the
|
||||
/// applications, and the processor's fee when one was withheld.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SettleParams {
|
||||
/// The settlement's date.
|
||||
pub date: NaiveDate,
|
||||
/// The entry's description.
|
||||
pub description: String,
|
||||
/// The account the cash reaches (or leaves): a bank, a card, a payable
|
||||
/// holding no role.
|
||||
pub cash_account: crate::AccountId,
|
||||
/// The customer or vendor every application names.
|
||||
pub party: u64,
|
||||
/// One or more documents, one party, one kind.
|
||||
pub applications: Vec<Application>,
|
||||
/// The processor's fee, on a customer payment only.
|
||||
pub fee: Option<Fee>,
|
||||
/// The payment method, when known (D-40).
|
||||
pub instrument: Option<crate::InstrumentId>,
|
||||
/// The processor's or bank's reference, when there is one.
|
||||
pub reference: Option<String>,
|
||||
}
|
||||
|
||||
/// One posted line a settlement's plan asks for, with the document it tags
|
||||
/// when it is a control line ([`Application`]'s payment or discount).
|
||||
pub type SettlementRow = (crate::AccountId, crate::Side, Decimal, Option<DocTag>);
|
||||
|
||||
/// How a document's payment terms count their days (rules.md B-39; plan
|
||||
/// P4b S4.1): from the document's date, from the end of its month, or a
|
||||
/// date the parties agreed.
|
||||
@@ -1167,6 +1225,35 @@ impl AgingRow {
|
||||
}
|
||||
}
|
||||
|
||||
/// One document in an aging's detail: its figures and the bucket it falls
|
||||
/// in (plan P4b S6, 8.3.10). Every figure is derived from its tagged lines
|
||||
/// ([`crate::Ledger::doc_state`]).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AgingDocument {
|
||||
/// The invoice or bill.
|
||||
pub doc: DocRef,
|
||||
/// The customer or vendor.
|
||||
pub party: String,
|
||||
/// Its own date.
|
||||
pub date: NaiveDate,
|
||||
/// When payment is due.
|
||||
pub due_date: NaiveDate,
|
||||
/// Its terms, as they print ("2/10 net 30"), when it has them.
|
||||
pub terms: Option<String>,
|
||||
/// The sum of its lines.
|
||||
pub total: Decimal,
|
||||
/// Cash paid that stands.
|
||||
pub paid: Decimal,
|
||||
/// Discounts taken that stand.
|
||||
pub discounted: Decimal,
|
||||
/// What it still owes as of the day.
|
||||
pub outstanding: Decimal,
|
||||
/// `current`, `days_30`, `days_60`, `days_90` or `days_90_plus`.
|
||||
pub bucket: &'static str,
|
||||
/// Days past its due date while something is owed; 0 otherwise.
|
||||
pub days_overdue: i64,
|
||||
}
|
||||
|
||||
/// Put an open balance in its aging bucket.
|
||||
///
|
||||
/// ```text
|
||||
@@ -1199,23 +1286,35 @@ pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) ->
|
||||
days_90: Decimal::ZERO,
|
||||
days_90_plus: Decimal::ZERO,
|
||||
};
|
||||
if due_date >= as_of {
|
||||
row.current = balance;
|
||||
} else {
|
||||
let days = (as_of - due_date).num_days();
|
||||
if days <= 30 {
|
||||
row.days_30 = balance;
|
||||
} else if days <= 60 {
|
||||
row.days_60 = balance;
|
||||
} else if days <= 90 {
|
||||
row.days_90 = balance;
|
||||
} else {
|
||||
row.days_90_plus = balance;
|
||||
}
|
||||
match bucket_key(due_date, as_of) {
|
||||
"current" => row.current = balance,
|
||||
"days_30" => row.days_30 = balance,
|
||||
"days_60" => row.days_60 = balance,
|
||||
"days_90" => row.days_90 = balance,
|
||||
_ => row.days_90_plus = balance,
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// The aging bucket a document due `due_date` falls in as of `as_of`, by
|
||||
/// the edges [`aging_bucket`] uses: `current` (due today is current),
|
||||
/// `days_30`, `days_60`, `days_90` or `days_90_plus`.
|
||||
pub fn bucket_key(due_date: NaiveDate, as_of: NaiveDate) -> &'static str {
|
||||
if due_date >= as_of {
|
||||
return "current";
|
||||
}
|
||||
let days = (as_of - due_date).num_days();
|
||||
if days <= 30 {
|
||||
"days_30"
|
||||
} else if days <= 60 {
|
||||
"days_60"
|
||||
} else if days <= 90 {
|
||||
"days_90"
|
||||
} else {
|
||||
"days_90_plus"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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
@@ -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<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 ¶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<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 ¶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<EntryId, LedgerError> {
|
||||
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<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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6206,100 +6654,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
|
||||
@@ -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<usize>,
|
||||
discount: Decimal,
|
||||
discount_line: Option<usize>,
|
||||
) -> 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<usize>,
|
||||
discount: Decimal,
|
||||
discount_line: Option<usize>,
|
||||
) -> 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<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
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user