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:
commit
be58059830
45 files changed
+3949
-520
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.
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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",
|
||||
@@ -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 ¶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).
|
||||
@@ -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
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -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