The fourth of the paused branches: Ledger::write_off and the bad_debts control role, invoice.written_off with its evidence filed in the same store call, books.write_off_invoice, the write-off on the timeline and in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with their seams restored, and: the scope table at 125; S7's law numbered 37; B-41 placed before B-42; the handbook's step 4 and recovery procedures folded into the one invoices-and-bills section; S7's wire tests in their own module with the confirm S4.4's contract needs; S7's copy of the payment steps dropped (S6 replaced them with Ledger::settle) and its write-off journaled through S6's journal_auto_lines; in the old-books fixture S7's hand-written write-off moved to a second invoice, since S6's hand-written payment settles invoice 1 in full. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
1676 lines
63 KiB
Rust
1676 lines
63 KiB
Rust
//! Accounts receivable and payable: customers, vendors, invoices, bills.
|
||
//!
|
||
//! Invoices and bills are business documents that auto-post journal entries:
|
||
//!
|
||
//! * `create_invoice`: Dr `1200` Accounts Receivable, Cr each line's account
|
||
//! (the revenue role, `4000`, when a line names none).
|
||
//! * `create_bill`: Dr each line's account (the general-expenses role,
|
||
//! `6999`, when a line names none), Cr `2000` Accounts Payable.
|
||
//! * `receive_payment`: Dr Cash, Cr A/R.
|
||
//! * `pay_bill`: Dr A/P, Cr Cash.
|
||
//!
|
||
//! A document keeps no figure of its own (plan P4b, D-45; rules.md B-38):
|
||
//! every ledger line on a receivables or payables account carries the
|
||
//! document it belongs to ([`DocTag`]), and what is paid and still owed,
|
||
//! and the document's status, are read from those lines that stand
|
||
//! ([`DocState`], `Ledger::doc_state`), as of any date. A reversal, a void
|
||
//! or a bounced payment is therefore right by construction, and the
|
||
//! control accounts always equal their documents.
|
||
//!
|
||
//! The control accounts (`1200`, `2000`) and the default revenue/expense
|
||
//! accounts (`4000`, `6999`) are created on demand if missing. Invoices and
|
||
//! bills respect closed periods: they cannot be created with dates in a
|
||
//! closed period.
|
||
|
||
use crate::LedgerError;
|
||
use chrono::{Datelike, Days, NaiveDate};
|
||
use rust_decimal::Decimal;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// The most days a payment term may run: ten years, far beyond any trade
|
||
/// term, and short of the calendar's end for every document date the
|
||
/// books can hold (B-39).
|
||
pub const MAX_TERMS_DAYS: u16 = 3_650;
|
||
|
||
/// Opaque customer handle.
|
||
pub type CustomerId = u64;
|
||
/// Opaque vendor handle.
|
||
pub type VendorId = u64;
|
||
/// Opaque invoice handle.
|
||
pub type InvoiceId = u64;
|
||
/// Opaque bill handle.
|
||
pub type BillId = u64;
|
||
|
||
/// A customer: someone we invoice.
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
pub struct Customer {
|
||
/// Opaque handle.
|
||
pub id: CustomerId,
|
||
/// Display name.
|
||
pub name: String,
|
||
}
|
||
|
||
/// A vendor: someone who bills us.
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
pub struct Vendor {
|
||
/// Opaque handle.
|
||
pub id: VendorId,
|
||
/// Display name.
|
||
pub name: String,
|
||
/// Its 1099 decisions, each from the date it takes effect, in date
|
||
/// order (one from the beginning first); none when nobody has decided
|
||
/// (see [`crate::form1099`], D-40).
|
||
#[serde(default)]
|
||
pub decisions_1099: Vec<crate::form1099::Dated1099>,
|
||
/// A W-9 (taxpayer identification number) is on file. The number
|
||
/// itself is never stored here.
|
||
#[serde(default)]
|
||
pub tin_on_file: bool,
|
||
}
|
||
|
||
impl Vendor {
|
||
/// The 1099 decision in force on `date`, if any was recorded by then.
|
||
pub fn decision_on(&self, date: NaiveDate) -> Option<&crate::form1099::Decision1099> {
|
||
crate::form1099::in_force(&self.decisions_1099, date).map(|d| &d.decision)
|
||
}
|
||
|
||
/// The latest 1099 decision recorded (the one in force from the last
|
||
/// effective date on), if any.
|
||
pub fn latest_1099(&self) -> Option<&crate::form1099::Dated1099> {
|
||
self.decisions_1099.last()
|
||
}
|
||
|
||
/// The return its latest decision files, if it files one.
|
||
pub fn form_1099(&self) -> Option<crate::form1099::Form1099> {
|
||
self.latest_1099().and_then(|d| d.decision.form())
|
||
}
|
||
}
|
||
|
||
/// Payment status of an invoice or bill, in the three words the
|
||
/// documents' records carried before their figures were derived
|
||
/// ([`Invoice::status`]). The full status, a void or a write-off
|
||
/// included, is [`DocStatus`].
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||
pub enum DocumentStatus {
|
||
/// Nothing paid yet.
|
||
Open,
|
||
/// Partially paid.
|
||
Partial,
|
||
/// Fully paid.
|
||
Paid,
|
||
}
|
||
|
||
/// The two kinds of document (an invoice or a bill), without an id: what
|
||
/// a line's account rule and the journal's event names key on.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum DocumentKind {
|
||
/// A sales invoice (receivables).
|
||
Invoice,
|
||
/// A vendor bill (payables).
|
||
Bill,
|
||
}
|
||
|
||
/// A document: an invoice or a bill, the two kinds whose ledger lines
|
||
/// carry their document ([`DocTag`]).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
|
||
pub enum DocRef {
|
||
/// A sales invoice (receivables).
|
||
Invoice(InvoiceId),
|
||
/// A vendor bill (payables).
|
||
Bill(BillId),
|
||
}
|
||
|
||
impl std::fmt::Display for DocRef {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
DocRef::Invoice(id) => write!(f, "invoice {id}"),
|
||
DocRef::Bill(id) => write!(f, "bill {id}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl DocRef {
|
||
/// The side that counts plus for this kind of document: a debit to
|
||
/// receivables raises what an invoice is owed, a credit to payables
|
||
/// what a bill is owed.
|
||
pub fn owed_side(self) -> crate::Side {
|
||
match self {
|
||
DocRef::Invoice(_) => crate::Side::Debit,
|
||
DocRef::Bill(_) => crate::Side::Credit,
|
||
}
|
||
}
|
||
|
||
/// A line's amount as this document counts it: plus on
|
||
/// [`Self::owed_side`], minus on the other.
|
||
pub fn counted(self, side: crate::Side, amount: Decimal) -> Decimal {
|
||
if side == self.owed_side() {
|
||
amount
|
||
} else {
|
||
-amount
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Why a ledger line carries its document: what it does to what is owed.
|
||
/// `Origin`, `Payment` and `WriteOff` are written today (S1, S7); the rest
|
||
/// are movements only a later unit may write (P4b.6), named here so a
|
||
/// tag's kind never changes meaning. `Adopted` and `RoleMove` are
|
||
/// deferred (D-49).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum Movement {
|
||
/// The document's own control line: what it is for.
|
||
Origin,
|
||
/// Cash paid against it.
|
||
Payment,
|
||
/// An early-payment discount taken (S6).
|
||
Discount,
|
||
/// A bad debt written off (S7).
|
||
WriteOff,
|
||
/// A legacy hand line adopted as the document (deferred, D-49).
|
||
Adopted,
|
||
/// The balance moved with its control role (deferred, D-49).
|
||
RoleMove,
|
||
}
|
||
|
||
/// A ledger line's document tag: which document the line belongs to, and
|
||
/// how ([`crate::Ledger::line_doc`]). Kept beside the line, like its
|
||
/// cleared flag: the line and its hash never change.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub struct DocTag {
|
||
/// The document.
|
||
pub doc: DocRef,
|
||
/// What the line does to it.
|
||
pub movement: Movement,
|
||
}
|
||
|
||
/// A document's status as of a date ([`DocState`]).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum DocStatus {
|
||
/// Nothing paid, discounted or written off.
|
||
Open,
|
||
/// Some of it settled, some still owed.
|
||
Partial,
|
||
/// Nothing owed, and some of it paid or discounted.
|
||
Paid,
|
||
/// Nothing owed, the last of it written off (S7).
|
||
WrittenOff,
|
||
/// Its own entry doesn't stand: reversed (B-9), number kept.
|
||
Void,
|
||
/// Less than nothing owed: more paid against it than it is for, a
|
||
/// credit balance owed back (to the customer on an invoice, by the
|
||
/// vendor on a bill). Only in books an earlier version left out of
|
||
/// bounds (B-38 INVARIANT 2); never a receivable or a payable, so
|
||
/// never aged.
|
||
Overpaid,
|
||
/// Its own entry stands but carries no line tagged with it (an older
|
||
/// version's entry replay couldn't match): its figures can't be read
|
||
/// from the ledger, and the control check lists it.
|
||
Unattached,
|
||
}
|
||
|
||
impl DocStatus {
|
||
/// Its wire name: `open`, `partial`, `paid`, `written_off`, `void`.
|
||
pub fn key(self) -> &'static str {
|
||
match self {
|
||
DocStatus::Open => "open",
|
||
DocStatus::Partial => "partial",
|
||
DocStatus::Paid => "paid",
|
||
DocStatus::WrittenOff => "written_off",
|
||
DocStatus::Void => "void",
|
||
DocStatus::Overpaid => "overpaid",
|
||
DocStatus::Unattached => "unattached",
|
||
}
|
||
}
|
||
|
||
/// Its label for people: `Open`, `Partly paid`, `Paid`, `Written
|
||
/// off`, `Void`, `Overpaid`, `Not in the ledger`.
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
DocStatus::Open => "Open",
|
||
DocStatus::Partial => "Partly paid",
|
||
DocStatus::Paid => "Paid",
|
||
DocStatus::WrittenOff => "Written off",
|
||
DocStatus::Void => "Void",
|
||
DocStatus::Overpaid => "Overpaid",
|
||
DocStatus::Unattached => "Not in the ledger",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What a document is, as of a date, read from the ledger lines tagged
|
||
/// with it ([`crate::Ledger::doc_state`]); never stored.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||
pub struct DocState {
|
||
/// The sum of its lines.
|
||
pub total: Decimal,
|
||
/// Cash paid against it that stands.
|
||
pub paid: Decimal,
|
||
/// Discounts taken that stand.
|
||
pub discounted: Decimal,
|
||
/// Amounts written off that stand.
|
||
pub written_off: Decimal,
|
||
/// What is still owed: the signed sum of every line tagged with it.
|
||
pub outstanding: Decimal,
|
||
/// Its status.
|
||
pub status: DocStatus,
|
||
/// Days past its due date while something is owed; 0 otherwise.
|
||
pub overdue_days: i64,
|
||
}
|
||
|
||
impl DocState {
|
||
/// It owes less than nothing or more than its total: books an earlier
|
||
/// version left out of bounds (B-38 INVARIANT 2; plan P4b.14 item 3),
|
||
/// which the control check lists for attention.
|
||
pub fn out_of_bounds(&self) -> bool {
|
||
self.outstanding < Decimal::ZERO || self.outstanding > self.total
|
||
}
|
||
}
|
||
|
||
/// A document's status from its figures ([`DocState`]; plan P4b.3).
|
||
///
|
||
/// ```text
|
||
/// GOAL: one status from the ledger's figures, whatever happened to the
|
||
/// document, its payments and their reversals.
|
||
/// SOURCE: docs/spec/rules.md B-38; plan/served.md P4b.3 ("Status as of D");
|
||
/// ASC 310-10 (a credit balance is not a receivable).
|
||
/// INVARIANTS:
|
||
/// 1. Void exactly when its own entry doesn't stand (`origin_stands`
|
||
/// false), whatever else is tagged.
|
||
/// 2. Otherwise Unattached when its own control line carries no tag
|
||
/// (`origin_tagged` false): nothing about it can be read.
|
||
/// 3. Otherwise: Overpaid when less than nothing is owed; Written off
|
||
/// when nothing is owed and something was written off; Paid when
|
||
/// nothing is owed; Partial when less than the total is owed; Open
|
||
/// when the whole total, or more (DocState::out_of_bounds), is.
|
||
/// 4. A pure function of its arguments.
|
||
/// ```
|
||
pub fn doc_status(
|
||
origin_stands: bool,
|
||
origin_tagged: bool,
|
||
total: Decimal,
|
||
outstanding: Decimal,
|
||
written_off: Decimal,
|
||
) -> DocStatus {
|
||
if !origin_stands {
|
||
DocStatus::Void
|
||
} else if !origin_tagged {
|
||
DocStatus::Unattached
|
||
} else if outstanding < Decimal::ZERO {
|
||
DocStatus::Overpaid
|
||
} else if outstanding.is_zero() && written_off > Decimal::ZERO {
|
||
DocStatus::WrittenOff
|
||
} else if outstanding.is_zero() {
|
||
DocStatus::Paid
|
||
} else if outstanding < total {
|
||
DocStatus::Partial
|
||
} else {
|
||
DocStatus::Open
|
||
}
|
||
}
|
||
|
||
/// One posted ledger line tagged with a document ([`crate::Ledger::doc_lines`]).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||
pub struct DocLine {
|
||
/// The entry.
|
||
pub entry: crate::EntryId,
|
||
/// The line's index in it.
|
||
pub line: usize,
|
||
/// The entry's date.
|
||
pub date: NaiveDate,
|
||
/// The account the line is on.
|
||
pub account: crate::AccountId,
|
||
/// What it does to the document.
|
||
pub movement: Movement,
|
||
/// Its amount as the document counts it ([`DocRef::counted`]): plus
|
||
/// raises what is owed.
|
||
pub amount: Decimal,
|
||
/// The entry reverses another (it is the mirror of a line).
|
||
pub reversal: bool,
|
||
}
|
||
|
||
/// A line on a control account that carries no document: one written by
|
||
/// hand before the control guard, or by an older version (plan P4b.3,
|
||
/// "unattached").
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||
pub struct UnattachedLine {
|
||
/// The entry.
|
||
pub entry: crate::EntryId,
|
||
/// The line's index in it.
|
||
pub line: usize,
|
||
/// The entry's date.
|
||
pub date: NaiveDate,
|
||
/// Its amount as the control account counts it (plus raises the
|
||
/// receivable or the payable).
|
||
pub amount: Decimal,
|
||
}
|
||
|
||
/// One control account's tie-out as of a date ([`crate::Ledger::control_check`]).
|
||
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||
pub struct ControlCheck {
|
||
/// The role whose documents the account holds: `receivables` or
|
||
/// `payables`.
|
||
pub role: String,
|
||
/// The account.
|
||
pub account: crate::AccountId,
|
||
/// Its balance in the general ledger, on its normal side.
|
||
pub balance: Decimal,
|
||
/// The sum of what its documents still owe on it.
|
||
pub documents_total: Decimal,
|
||
/// The lines on it that carry no document, which still stand.
|
||
pub unattached: Vec<UnattachedLine>,
|
||
/// balance - documents_total - the unattached lines: always zero (both
|
||
/// sides sum the same lines); anything else is an engine error.
|
||
pub difference: Decimal,
|
||
/// The role's documents needing attention as of the day: out of
|
||
/// bounds ([`DocState::out_of_bounds`]: a credit balance, which aging
|
||
/// leaves out, or more owed than the total) or unattached
|
||
/// ([`DocStatus::Unattached`]). Listed on the role's first account.
|
||
pub attention: Vec<DocAttention>,
|
||
}
|
||
|
||
/// A document the control check lists for attention ([`ControlCheck::attention`]).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||
pub struct DocAttention {
|
||
/// The document.
|
||
pub doc: DocRef,
|
||
/// Its status as of the day.
|
||
pub status: DocStatus,
|
||
/// Its total.
|
||
pub total: Decimal,
|
||
/// What it owes as of the day.
|
||
pub outstanding: Decimal,
|
||
}
|
||
|
||
/// How a payment is recorded beyond its amount (plan P4b.6, D-40): the
|
||
/// payment method it moved through, tagged on its entry in the same call
|
||
/// (so a card-paid bill is left out of 1099-NEC without a second call),
|
||
/// and the processor's or bank's own reference (a charge id, a trace or
|
||
/// check number), carried in the entry's description.
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||
pub struct PaymentDetails {
|
||
/// The payment method, when known.
|
||
pub instrument: Option<crate::InstrumentId>,
|
||
/// The reference, when there is one: one line of text.
|
||
pub reference: Option<String>,
|
||
}
|
||
|
||
/// The longest payment reference taken, in characters.
|
||
pub const MAX_REFERENCE_CHARS: usize = 200;
|
||
|
||
impl PaymentDetails {
|
||
/// The payment entry's description: `base`, then the reference when
|
||
/// there is one (`base (ref R)`).
|
||
pub fn description(&self, base: &str) -> String {
|
||
match self.reference.as_deref().map(str::trim) {
|
||
Some(r) if !r.is_empty() => format!("{base} (ref {r})"),
|
||
_ => base.to_string(),
|
||
}
|
||
}
|
||
|
||
/// Refuse a reference that isn't one line of at most
|
||
/// [`MAX_REFERENCE_CHARS`] characters. A blank one is none. (A bank
|
||
/// trace number is fifteen digits, so no nine-digit scan here.)
|
||
pub fn check(&self) -> Result<(), LedgerError> {
|
||
if let Some(r) = &self.reference
|
||
&& (r.contains(['\n', '\r']) || r.chars().count() > MAX_REFERENCE_CHARS)
|
||
{
|
||
return Err(LedgerError::InvalidPayment(format!(
|
||
"a payment's reference is one line of at most {MAX_REFERENCE_CHARS} characters"
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum TermsKind {
|
||
/// Due on the document's own date.
|
||
DueOnReceipt,
|
||
/// Due `n` days after the document's date (Net n).
|
||
Net(u16),
|
||
/// Due `n` days after the last day of the document's month (Net n
|
||
/// EOM).
|
||
NetEom(u16),
|
||
/// Due on a date the parties agreed; the caller passes it to
|
||
/// [`Terms::due_date`], and [`Terms::describe`] carries it for
|
||
/// people.
|
||
Custom,
|
||
}
|
||
|
||
/// An early-payment discount: take `percent_bp` off if paid within
|
||
/// `days` of the document's date ("2/10 net 30").
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct EarlyDiscount {
|
||
/// The discount in basis points: 1 to 9,999 (0.01% to 99.99%).
|
||
pub percent_bp: u32,
|
||
/// The days after the document's date the discount is open.
|
||
pub days: u16,
|
||
}
|
||
|
||
/// Payment terms: one due date, and the early-payment discount open
|
||
/// before it (rules.md B-39; plan P4b S4.1, A-2, A-8).
|
||
///
|
||
/// ```text
|
||
/// GOAL: terms give one due date and one discount date, exactly.
|
||
/// SOURCE: docs/spec/rules.md B-39; plan/served.md 8.3.13 (B-39);
|
||
/// CONVENTION: standard trade-terms notation; UCC §2-310(a) for
|
||
/// goods; for services, the contract.
|
||
/// INVARIANTS:
|
||
/// 1. Net n = date + n; Net n EOM = the last day of date's month + n;
|
||
/// due on receipt = date; Custom = the given date. Every result is
|
||
/// >= the document date and in the calendar (calendar::check); n is
|
||
/// 0..=3650.
|
||
/// 2. discount_by = date + d, d < the net days; no discount with due on
|
||
/// receipt, EOM or custom terms.
|
||
/// 3. describe() is the canonical text; parse() accepts it and the
|
||
/// canonical spellings, case-insensitively, and nothing else.
|
||
/// ```
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct Terms {
|
||
/// How the due date counts.
|
||
kind: TermsKind,
|
||
/// The early-payment discount, when there is one.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
discount: Option<EarlyDiscount>,
|
||
/// The canonical text: "Net 30", "Net 15 EOM", "2/10 net 30", "Due on
|
||
/// receipt", "Due 2026-11-15".
|
||
text: String,
|
||
}
|
||
|
||
impl Terms {
|
||
/// Terms for `kind`, with `discount` when given, canonical text
|
||
/// composed. Refused when the days are past [`MAX_TERMS_DAYS`], the
|
||
/// discount is outside 1..=9,999 basis points, or a discount is asked
|
||
/// for terms that count no net days (due on receipt, EOM or custom).
|
||
pub fn new(kind: TermsKind, discount: Option<EarlyDiscount>) -> Result<Self, LedgerError> {
|
||
if let TermsKind::Net(n) | TermsKind::NetEom(n) = kind
|
||
&& n > MAX_TERMS_DAYS
|
||
{
|
||
return Err(LedgerError::TermsUnreadable(format!(
|
||
"net days {n} is outside 0..={MAX_TERMS_DAYS}"
|
||
)));
|
||
}
|
||
if let Some(d) = discount {
|
||
if !(1..=9_999).contains(&d.percent_bp) {
|
||
return Err(LedgerError::TermsUnreadable(format!(
|
||
"discount {} basis points is outside 1..=9999",
|
||
d.percent_bp
|
||
)));
|
||
}
|
||
match kind {
|
||
TermsKind::Net(n) if d.days < n => {}
|
||
_ => return Err(LedgerError::TermsDiscountNeedsNetDays),
|
||
}
|
||
}
|
||
// Custom terms are a date; without one there is nothing to print
|
||
// or derive (Terms::custom is the way to give it).
|
||
if kind == TermsKind::Custom {
|
||
return Err(LedgerError::TermsUnreadable(
|
||
"custom terms need their date: use Terms::custom".into(),
|
||
));
|
||
}
|
||
Ok(Terms {
|
||
kind,
|
||
discount,
|
||
text: compose_terms_text(kind, discount),
|
||
})
|
||
}
|
||
|
||
/// Custom terms due on `due`: the parties agreed a date rather than a
|
||
/// count of days. Its text carries the date for people; the document
|
||
/// stores the due date itself.
|
||
pub fn custom(due: NaiveDate) -> Result<Self, LedgerError> {
|
||
crate::calendar::check(due)?;
|
||
Ok(Terms {
|
||
kind: TermsKind::Custom,
|
||
discount: None,
|
||
text: format!("Due {due}"),
|
||
})
|
||
}
|
||
|
||
/// How the due date counts.
|
||
pub fn kind(&self) -> TermsKind {
|
||
self.kind
|
||
}
|
||
|
||
/// The early-payment discount, when there is one.
|
||
pub fn discount(&self) -> Option<EarlyDiscount> {
|
||
self.discount
|
||
}
|
||
|
||
/// The canonical text ("Net 30", "2/10 net 30", "Due 2026-11-15").
|
||
pub fn describe(&self) -> String {
|
||
self.text.clone()
|
||
}
|
||
|
||
/// The date payment is due for a document dated `date`.
|
||
///
|
||
/// For [`TermsKind::Custom`] the caller passes the agreed date itself
|
||
/// (there is no count to derive it from); for every other kind `date`
|
||
/// is the document's date. The result is never before `date` and
|
||
/// always in the calendar: a due date past 9999-12-31 is refused
|
||
/// ([`LedgerError::DateOverflow`]), never a panic.
|
||
pub fn due_date(&self, date: NaiveDate) -> Result<NaiveDate, LedgerError> {
|
||
crate::calendar::check(date)?;
|
||
let due = match self.kind {
|
||
TermsKind::DueOnReceipt | TermsKind::Custom => date,
|
||
TermsKind::Net(n) => add_days(date, n)?,
|
||
TermsKind::NetEom(n) => add_days(last_day_of_month(date)?, n)?,
|
||
};
|
||
validate_due_date(date, due)?;
|
||
Ok(due)
|
||
}
|
||
|
||
/// The last date the early-payment discount is open for a document
|
||
/// dated `date`: `date + discount.days`, or `None` when there is no
|
||
/// discount or the date is outside the calendar.
|
||
pub fn discount_by(&self, date: NaiveDate) -> Option<NaiveDate> {
|
||
let d = self.discount?;
|
||
let by = date.checked_add_days(Days::new(u64::from(d.days)))?;
|
||
crate::calendar::check(by).ok()
|
||
}
|
||
|
||
/// Read the canonical spellings, case-insensitively: "Net 30", "Net 15
|
||
/// EOM", "2/10 net 30", "Due on receipt", "Due 2026-11-15". Anything
|
||
/// else is [`LedgerError::TermsUnreadable`].
|
||
pub fn parse(text: &str) -> Result<Self, LedgerError> {
|
||
let raw = text.trim();
|
||
let lower = raw.to_lowercase();
|
||
let unreadable = || LedgerError::TermsUnreadable(raw.to_string());
|
||
if lower == "due on receipt" {
|
||
return Terms::new(TermsKind::DueOnReceipt, None);
|
||
}
|
||
if let Some(rest) = lower.strip_prefix("due ") {
|
||
let due =
|
||
NaiveDate::parse_from_str(rest.trim(), "%Y-%m-%d").map_err(|_| unreadable())?;
|
||
return Terms::custom(due).map_err(|_| unreadable());
|
||
}
|
||
// "2/10 net 30" (an optional decimal percentage), or "net 30" /
|
||
// "net 15 eom".
|
||
let (percent, days, net, eom) = match lower.split_once('/') {
|
||
Some((pct, rest)) => {
|
||
let (days, net_part) = rest.trim().split_once(' ').ok_or_else(unreadable)?;
|
||
let after = net_part
|
||
.trim()
|
||
.strip_prefix("net ")
|
||
.ok_or_else(unreadable)?
|
||
.trim();
|
||
let (net, eom) = match after.strip_suffix(" eom") {
|
||
Some(n) => (n, true),
|
||
None => (after, false),
|
||
};
|
||
(
|
||
Some(parse_percent(pct).ok_or_else(unreadable)?),
|
||
days,
|
||
net,
|
||
eom,
|
||
)
|
||
}
|
||
None => {
|
||
let rest = lower.strip_prefix("net ").ok_or_else(unreadable)?;
|
||
let (net, eom) = match rest.trim().strip_suffix(" eom") {
|
||
Some(n) => (n, true),
|
||
None => (rest.trim(), false),
|
||
};
|
||
(None, "0", net, eom)
|
||
}
|
||
};
|
||
let net: u16 = net.trim().parse().map_err(|_| unreadable())?;
|
||
let discount = match (percent, days) {
|
||
(Some(percent_bp), days_raw) => {
|
||
let days: u16 = days_raw.parse().map_err(|_| unreadable())?;
|
||
Some(EarlyDiscount { percent_bp, days })
|
||
}
|
||
(None, _) => None,
|
||
};
|
||
let kind = if eom {
|
||
TermsKind::NetEom(net)
|
||
} else {
|
||
TermsKind::Net(net)
|
||
};
|
||
Terms::new(kind, discount).map_err(|_| unreadable())
|
||
}
|
||
}
|
||
|
||
/// The canonical text of terms: `"2/10 net 30"` with a discount, else
|
||
/// `"Net 30"`, `"Net 15 EOM"` or `"Due on receipt"`.
|
||
fn compose_terms_text(kind: TermsKind, discount: Option<EarlyDiscount>) -> String {
|
||
match (discount, kind) {
|
||
(Some(EarlyDiscount { percent_bp, days }), TermsKind::Net(n)) => {
|
||
format!("{}/{} net {n}", percent_text(percent_bp), days)
|
||
}
|
||
(_, TermsKind::Net(n)) => format!("Net {n}"),
|
||
(_, TermsKind::NetEom(n)) => format!("Net {n} EOM"),
|
||
(_, TermsKind::DueOnReceipt) => "Due on receipt".to_string(),
|
||
(_, TermsKind::Custom) => "Due".to_string(),
|
||
}
|
||
}
|
||
|
||
/// Basis points as the percentage trade terms print: 200 is "2", 250 is
|
||
/// "2.5", 205 is "2.05".
|
||
fn percent_text(percent_bp: u32) -> String {
|
||
let whole = percent_bp / 100;
|
||
let frac = percent_bp % 100;
|
||
match (whole, frac) {
|
||
(w, 0) => w.to_string(),
|
||
(w, f) if f % 10 == 0 => format!("{w}.{}", f / 10),
|
||
(w, f) => format!("{w}.{f:02}"),
|
||
}
|
||
}
|
||
|
||
/// A percentage in trade terms as basis points: "2" is 200, "2.5" is 250,
|
||
/// "2.05" is 205. None when it isn't a percentage with at most two
|
||
/// decimal places.
|
||
fn parse_percent(s: &str) -> Option<u32> {
|
||
let (whole, frac) = match s.trim().split_once('.') {
|
||
Some((w, f)) => (w, f),
|
||
None => (s.trim(), ""),
|
||
};
|
||
if frac.len() > 2 || !frac.chars().all(|c| c.is_ascii_digit()) {
|
||
return None;
|
||
}
|
||
let whole: u32 = whole.parse().ok()?;
|
||
let frac: u32 = if frac.is_empty() {
|
||
0
|
||
} else {
|
||
format!("{frac:0<2}").parse().ok()?
|
||
};
|
||
whole.checked_mul(100)?.checked_add(frac)
|
||
}
|
||
|
||
/// `date` plus `n` days, or [`LedgerError::DateOverflow`] past the
|
||
/// calendar's end.
|
||
fn add_days(date: NaiveDate, n: u16) -> Result<NaiveDate, LedgerError> {
|
||
date.checked_add_days(Days::new(u64::from(n)))
|
||
.ok_or(LedgerError::DateOverflow(date))
|
||
}
|
||
|
||
/// The last day of `date`'s month, or [`LedgerError::DateOverflow`] when
|
||
/// there is no such date in the calendar.
|
||
fn last_day_of_month(date: NaiveDate) -> Result<NaiveDate, LedgerError> {
|
||
let (year, month) = if date.month() == 12 {
|
||
(
|
||
date.year()
|
||
.checked_add(1)
|
||
.ok_or(LedgerError::DateOverflow(date))?,
|
||
1,
|
||
)
|
||
} else {
|
||
(date.year(), date.month() + 1)
|
||
};
|
||
NaiveDate::from_ymd_opt(year, month, 1)
|
||
.and_then(|first| first.pred_opt())
|
||
.ok_or(LedgerError::DateOverflow(date))
|
||
}
|
||
|
||
/// The parties on a document, as they were when it was issued (plan P4b
|
||
/// S4.1, A-13; the contact block, D-55): a name, and the address and email
|
||
/// that were on the document, with the phone, contact person, billing and
|
||
/// shipping details when the party had them. Never today's party record
|
||
/// when a snapshot exists.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct PartySnapshot {
|
||
/// The party's name at issue.
|
||
pub name: String,
|
||
/// Its address at issue, as the document printed it: the billing
|
||
/// address when the party has one, else its general one (D-55).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub address: Option<String>,
|
||
/// Its email at issue, as the document printed it: the billing email
|
||
/// when the party has one, else its general one (D-55).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub email: Option<String>,
|
||
/// Its telephone number at issue (D-55).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub phone: Option<String>,
|
||
/// The person there to write to, at issue (D-55).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub contact: Option<String>,
|
||
/// Where its bills go, when it said so (D-55). [`Self::address`] is
|
||
/// this one whenever it is set.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub billing_address: Option<String>,
|
||
/// Where goods go, when it said so (D-55): the issued page prints a
|
||
/// Ship-to line for it.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub shipping_address: Option<String>,
|
||
/// Where its bills are emailed, when it said so (D-55). [`Self::email`]
|
||
/// is this one whenever it is set.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub billing_email: Option<String>,
|
||
}
|
||
|
||
impl PartySnapshot {
|
||
/// The snapshot of `party` at issue (A-13, D-55): its name, and the
|
||
/// contact details as they stood, with the billing address and billing
|
||
/// email in place of the general ones when it has them. The one place
|
||
/// the bill-to rule lives, so every document agrees.
|
||
pub fn from_party(party: &crate::Party) -> Self {
|
||
PartySnapshot {
|
||
name: party
|
||
.display_name
|
||
.clone()
|
||
.unwrap_or_else(|| party.name.clone()),
|
||
address: party
|
||
.billing_address
|
||
.clone()
|
||
.or_else(|| party.address.clone()),
|
||
email: party.billing_email.clone().or_else(|| party.email.clone()),
|
||
phone: party.phone.clone(),
|
||
contact: party.contact.clone(),
|
||
billing_address: party.billing_address.clone(),
|
||
shipping_address: party.shipping_address.clone(),
|
||
billing_email: party.billing_email.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How an invoice was sent (plan P4b S5; the type S4.1 adds so
|
||
/// [`Invoice::sends`] has one from the start).
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum SentVia {
|
||
/// Email.
|
||
Email,
|
||
/// Paper mail.
|
||
Mail,
|
||
/// A customer or vendor portal.
|
||
Portal,
|
||
/// Handed over.
|
||
Hand,
|
||
/// Another channel, named.
|
||
Other(String),
|
||
}
|
||
|
||
impl SentVia {
|
||
/// Its key as the journal, the tools and the viewer name it: `email`,
|
||
/// `mail`, `portal`, `hand`, or `other:NAME` for another channel,
|
||
/// named (plan P4b S5).
|
||
pub fn key(&self) -> String {
|
||
match self {
|
||
SentVia::Email => "email".to_string(),
|
||
SentVia::Mail => "mail".to_string(),
|
||
SentVia::Portal => "portal".to_string(),
|
||
SentVia::Hand => "hand".to_string(),
|
||
SentVia::Other(name) => format!("other:{name}"),
|
||
}
|
||
}
|
||
|
||
/// [`Self::key`] the other way; `None` for a channel this version
|
||
/// doesn't know (a newer version's `sent_via`).
|
||
pub fn parse(key: &str) -> Option<SentVia> {
|
||
match key {
|
||
"email" => Some(SentVia::Email),
|
||
"mail" => Some(SentVia::Mail),
|
||
"portal" => Some(SentVia::Portal),
|
||
"hand" => Some(SentVia::Hand),
|
||
other => other
|
||
.strip_prefix("other:")
|
||
.filter(|name| !name.trim().is_empty())
|
||
.map(|name| SentVia::Other(name.to_string())),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether a send was the original or a reminder (plan P4b S5).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum SendKind {
|
||
/// The document first went out.
|
||
Original,
|
||
/// It went out again.
|
||
Reminder,
|
||
}
|
||
|
||
impl SendKind {
|
||
/// Its key as the journal, the tools and the viewer name it.
|
||
pub fn key(self) -> &'static str {
|
||
match self {
|
||
SendKind::Original => "original",
|
||
SendKind::Reminder => "reminder",
|
||
}
|
||
}
|
||
|
||
/// [`Self::key`] the other way; `None` for a kind this version
|
||
/// doesn't know.
|
||
pub fn parse(key: &str) -> Option<SendKind> {
|
||
match key {
|
||
"original" => Some(SendKind::Original),
|
||
"reminder" => Some(SendKind::Reminder),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One recorded send of an invoice (plan P4b S5): who sent it, how, to
|
||
/// whom, and the channel's own reference. A send changes no figure.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct Sent {
|
||
/// The day it went out.
|
||
pub on: NaiveDate,
|
||
/// Who sent it: the owner, or a named delegate such as an agent (A-4).
|
||
pub by: String,
|
||
/// The channel.
|
||
pub via: SentVia,
|
||
/// The recipients.
|
||
pub to: Vec<String>,
|
||
/// Original or reminder.
|
||
pub kind: SendKind,
|
||
/// The channel's own id (a message id), when there is one.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub reference: Option<String>,
|
||
/// A note about the send, when there is one.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub note: Option<String>,
|
||
}
|
||
|
||
/// The fields a new invoice carries beyond its figures (plan P4b S4.1).
|
||
/// Every one is optional: all `None` is the invoice every earlier version
|
||
/// wrote, posted and journaled exactly as before.
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||
pub struct InvoiceFields {
|
||
/// The number to issue it under ([`crate::Ledger::next_invoice_number`]).
|
||
pub number: Option<String>,
|
||
/// Its payment terms.
|
||
pub terms: Option<Terms>,
|
||
/// The customer as the document prints it.
|
||
pub bill_to: Option<PartySnapshot>,
|
||
/// Us as the document prints us.
|
||
pub seller: Option<PartySnapshot>,
|
||
/// The customer's own reference (their purchase order).
|
||
pub customer_ref: Option<String>,
|
||
/// A memo printed on the document.
|
||
pub memo: Option<String>,
|
||
}
|
||
|
||
/// The fields a new bill carries beyond its figures (plan P4b S4.1).
|
||
/// Every one is optional: all `None` is the bill every earlier version
|
||
/// wrote, posted and journaled exactly as before.
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||
pub struct BillFields {
|
||
/// The vendor's own invoice number.
|
||
pub vendor_ref: Option<String>,
|
||
/// The day we received the bill, when it differs from its date.
|
||
pub received_on: Option<NaiveDate>,
|
||
/// Its payment terms.
|
||
pub terms: Option<Terms>,
|
||
/// A memo about the bill.
|
||
pub memo: Option<String>,
|
||
/// Why a vendor invoice number that already names a standing bill is
|
||
/// entered again anyway. Journaled with the bill (S4.3); never part of
|
||
/// the record itself.
|
||
pub duplicate_reason: Option<String>,
|
||
}
|
||
|
||
/// One line on an invoice or bill.
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
pub struct DocumentLine {
|
||
/// Description of the goods/services.
|
||
pub description: String,
|
||
/// Line amount (positive).
|
||
pub amount: Decimal,
|
||
/// The project (a class) the line's revenue or cost belongs to, if
|
||
/// any (rules.md B-37): the posted entry's revenue or expense line for
|
||
/// it carries the tag; the receivable or payable line never does.
|
||
/// Documents journaled before the `document_line_projects` feature
|
||
/// have none.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub class_id: Option<crate::ClassId>,
|
||
/// The account the line's revenue, expense or asset goes to (plan P4b
|
||
/// S4.1, A-10): an invoice line's revenue or a liability holding no
|
||
/// role; a bill line's expense or a non-cash asset holding no role.
|
||
/// None is the kind's role account (revenue for an invoice, general
|
||
/// expenses for a bill), which is also what every legacy line means.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub account: Option<crate::AccountId>,
|
||
/// How many units the line bills, when it gives a quantity and price
|
||
/// rather than one amount. It and `unit_price` are both given or
|
||
/// neither, and `quantity × unit_price` must equal `amount` exactly
|
||
/// (plan P4b S4.1).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub quantity: Option<Decimal>,
|
||
/// The price of one unit, when the line gives a quantity. See
|
||
/// [`Self::quantity`].
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub unit_price: Option<Decimal>,
|
||
}
|
||
|
||
/// A sales invoice: we billed a customer.
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
pub struct Invoice {
|
||
/// Opaque handle.
|
||
pub id: InvoiceId,
|
||
/// The customer.
|
||
pub customer_id: CustomerId,
|
||
/// Invoice date.
|
||
pub date: NaiveDate,
|
||
/// When payment is due.
|
||
pub due_date: NaiveDate,
|
||
/// Line items.
|
||
pub lines: Vec<DocumentLine>,
|
||
/// What was settled on it at the end of the books: paid, discounted
|
||
/// and written off, as they stand ([`crate::Ledger::doc_state`] as of
|
||
/// the last day). Derived from the lines tagged with it and refreshed
|
||
/// by the ledger with every change (reversals included); never
|
||
/// journaled, never state of its own. A void settles nothing, so a
|
||
/// voided document never reads as paid.
|
||
pub amount_paid: Decimal,
|
||
/// What is still owed on it at the end of the books (`doc_state`'s
|
||
/// outstanding as of the last day), derived with
|
||
/// [`Self::amount_paid`]: [`Self::balance`]. Nothing for a void.
|
||
/// Never serialized: a figure, not part of the record.
|
||
#[serde(skip)]
|
||
pub owed: Decimal,
|
||
/// The three-word status of [`Self::amount_paid`]; derived with it
|
||
/// (Open for a void: nothing was paid). The full status (a void, a
|
||
/// write-off) is `doc_state`'s.
|
||
pub status: DocumentStatus,
|
||
/// The auto-posted journal entry.
|
||
pub entry_id: u64,
|
||
/// The number it was issued under ("INV-0007"), from
|
||
/// [`crate::Ledger::next_invoice_number`]; stored at issue, never
|
||
/// recomputed, kept by a void (A-2, B-40). None on a legacy invoice:
|
||
/// it is shown as "Invoice {id}" and never counted.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub number: Option<String>,
|
||
/// Its payment terms, when it has them.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub terms: Option<Terms>,
|
||
/// The customer as the document printed it, when it printed one.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub bill_to: Option<PartySnapshot>,
|
||
/// Us as the document printed us, when it printed us.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub seller: Option<PartySnapshot>,
|
||
/// The customer's own reference (their purchase order), when given.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub customer_ref: Option<String>,
|
||
/// A memo printed on the document, when given.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub memo: Option<String>,
|
||
/// Every recorded send, in order (plan P4b S5; empty until then).
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub sends: Vec<Sent>,
|
||
}
|
||
|
||
/// The sum of a document's lines; refused (TooLargeToCompute), never a
|
||
/// panic, past exact arithmetic. A document is restored from its journal
|
||
/// line as written (rules.md B-28), so its lines are bounded only by what
|
||
/// the version that wrote it checked.
|
||
pub fn document_total(lines: &[DocumentLine], what: &str) -> Result<Decimal, LedgerError> {
|
||
lines
|
||
.iter()
|
||
.try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount))
|
||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s total")))
|
||
}
|
||
|
||
/// The account and project a document's lines group by (plan P4b S4.1):
|
||
/// None for either means the kind's role account (an invoice's revenue, a
|
||
/// bill's general expenses) and no project.
|
||
pub type DocumentPartKey = (Option<crate::AccountId>, Option<crate::ClassId>);
|
||
|
||
/// The revenue (an invoice's), expense or asset (a bill's) side of a
|
||
/// document's entry: one part per (account, project) the lines name, in
|
||
/// the order each first appears, the lines naming neither together as one
|
||
/// part (`(None, None)`), each the sum of its lines ([`document_total`]).
|
||
///
|
||
/// ```text
|
||
/// GOAL: a document's lines post to their accounts and count toward their
|
||
/// projects, and a document whose lines name neither posts exactly
|
||
/// as before.
|
||
/// SOURCE: docs/spec/rules.md B-37 item 3 (job costing: the revenue and
|
||
/// direct costs identified with one job) and item 8 (an
|
||
/// invoice's or bill's line names its project); B-39 and plan
|
||
/// P4b S4.1 A-10 (every bill line names its account).
|
||
/// INVARIANTS:
|
||
/// 1. The parts sum to the document's total (conservation), and each
|
||
/// line is in exactly one part.
|
||
/// 2. No two parts name the same account and project; no part is empty.
|
||
/// 3. Lines naming neither make one part, so a document with no new
|
||
/// fields is one part: the one line every document posted before
|
||
/// S4.1.
|
||
/// 4. NEVER panics: TooLargeToCompute past exact arithmetic.
|
||
/// ```
|
||
pub fn document_parts(
|
||
lines: &[DocumentLine],
|
||
what: &str,
|
||
) -> Result<Vec<(DocumentPartKey, Decimal)>, LedgerError> {
|
||
let mut keys: Vec<DocumentPartKey> = Vec::new();
|
||
for l in lines {
|
||
let key = (l.account, l.class_id);
|
||
if !keys.contains(&key) {
|
||
keys.push(key);
|
||
}
|
||
}
|
||
keys.into_iter()
|
||
.map(|k| {
|
||
let part: Vec<DocumentLine> = lines
|
||
.iter()
|
||
.filter(|l| (l.account, l.class_id) == k)
|
||
.cloned()
|
||
.collect();
|
||
Ok((k, document_total(&part, what)?))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
impl Invoice {
|
||
/// Total of all lines ([`document_total`]).
|
||
pub fn total(&self) -> Result<Decimal, LedgerError> {
|
||
document_total(&self.lines, &format!("invoice {}", self.id))
|
||
}
|
||
|
||
/// Amount still owed at the end of the books ([`Self::owed`]); as
|
||
/// of a date, `Ledger::doc_state`. Refused (TooLargeToCompute) when
|
||
/// its total can't be summed.
|
||
pub fn balance(&self) -> Result<Decimal, LedgerError> {
|
||
self.total()?;
|
||
Ok(self.owed)
|
||
}
|
||
}
|
||
|
||
/// A vendor bill: we owe a vendor.
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
pub struct Bill {
|
||
/// Opaque handle.
|
||
pub id: BillId,
|
||
/// The vendor.
|
||
pub vendor_id: VendorId,
|
||
/// Bill date.
|
||
pub date: NaiveDate,
|
||
/// When payment is due.
|
||
pub due_date: NaiveDate,
|
||
/// Line items.
|
||
pub lines: Vec<DocumentLine>,
|
||
/// What was settled on it at the end of the books: paid, discounted
|
||
/// and written off, as they stand ([`crate::Ledger::doc_state`] as of
|
||
/// the last day). Derived from the lines tagged with it and refreshed
|
||
/// by the ledger with every change (reversals included); never
|
||
/// journaled, never state of its own. A void settles nothing, so a
|
||
/// voided document never reads as paid.
|
||
pub amount_paid: Decimal,
|
||
/// What is still owed on it at the end of the books (`doc_state`'s
|
||
/// outstanding as of the last day), derived with
|
||
/// [`Self::amount_paid`]: [`Self::balance`]. Nothing for a void.
|
||
/// Never serialized: a figure, not part of the record.
|
||
#[serde(skip)]
|
||
pub owed: Decimal,
|
||
/// The three-word status of [`Self::amount_paid`]; derived with it
|
||
/// (Open for a void: nothing was paid). The full status (a void, a
|
||
/// write-off) is `doc_state`'s.
|
||
pub status: DocumentStatus,
|
||
/// The auto-posted journal entry.
|
||
pub entry_id: u64,
|
||
/// The vendor's own invoice number, when given: what a duplicate
|
||
/// check reads (B-40; [`crate::Ledger::check_vendor_ref`]).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub vendor_ref: Option<String>,
|
||
/// The day we received the bill, when it differs from its date.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub received_on: Option<NaiveDate>,
|
||
/// Its payment terms, when it has them.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub terms: Option<Terms>,
|
||
/// A memo about the bill, when given.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub memo: Option<String>,
|
||
}
|
||
|
||
impl Bill {
|
||
/// Total of all lines ([`document_total`]).
|
||
pub fn total(&self) -> Result<Decimal, LedgerError> {
|
||
document_total(&self.lines, &format!("bill {}", self.id))
|
||
}
|
||
|
||
/// Amount still owed at the end of the books ([`Self::owed`]); as
|
||
/// of a date, `Ledger::doc_state`. Refused (TooLargeToCompute) when
|
||
/// its total can't be summed.
|
||
pub fn balance(&self) -> Result<Decimal, LedgerError> {
|
||
self.total()?;
|
||
Ok(self.owed)
|
||
}
|
||
}
|
||
|
||
/// Validate document lines: at least one, all positive amounts, each in a
|
||
/// journal line's range, and a quantity and unit price that multiply to
|
||
/// the amount exactly (plan P4b S4.1). For new documents; replay restores
|
||
/// them as journaled (rules.md B-28).
|
||
pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
|
||
if lines.is_empty() {
|
||
return Err(LedgerError::EmptyDocumentLines);
|
||
}
|
||
for line in lines {
|
||
if line.amount <= Decimal::ZERO {
|
||
return Err(LedgerError::InvalidDocumentLineAmount(line.amount));
|
||
}
|
||
// No larger than a journal line may be (kernel::MAX_LINE_AMOUNT):
|
||
// the document's total is summed from them (P0 review round 3, S1).
|
||
if !crate::kernel::Amount::in_line_range(line.amount) {
|
||
return Err(LedgerError::ValueOutOfRange(format!(
|
||
"document line amount {}",
|
||
line.amount
|
||
)));
|
||
}
|
||
match (line.quantity, line.unit_price) {
|
||
(None, None) => {}
|
||
(Some(quantity), Some(unit_price)) => {
|
||
if quantity <= Decimal::ZERO || unit_price <= Decimal::ZERO {
|
||
return Err(LedgerError::LineArithmetic(format!(
|
||
"line '{}': quantity {quantity} and unit price {unit_price} must both be positive",
|
||
line.description
|
||
)));
|
||
}
|
||
let product =
|
||
crate::kernel::line_amount(quantity, unit_price).ok_or_else(|| {
|
||
LedgerError::LineArithmetic(format!(
|
||
"line '{}': {quantity} x {unit_price} is too large to compute exactly",
|
||
line.description
|
||
))
|
||
})?;
|
||
if product != line.amount {
|
||
return Err(LedgerError::LineArithmetic(format!(
|
||
"line '{}': {quantity} x {unit_price} = {product}, not the line's amount {}; the engine never rounds a quantity times a price",
|
||
line.description, line.amount
|
||
)));
|
||
}
|
||
}
|
||
(quantity, unit_price) => {
|
||
return Err(LedgerError::LineArithmetic(format!(
|
||
"line '{}': a quantity and a unit price are both given or neither (quantity {quantity:?}, unit price {unit_price:?})",
|
||
line.description
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Validate that due date is not before the document date, and both are
|
||
/// in the calendar the books keep (P0 review round 3, S1). For new
|
||
/// documents; replay restores them as journaled (rules.md B-28).
|
||
pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), LedgerError> {
|
||
crate::calendar::check(date)?;
|
||
crate::calendar::check(due_date)?;
|
||
if due_date < date {
|
||
return Err(LedgerError::DueBeforeDate(due_date.to_string()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The three-word status from what is settled vs the total
|
||
/// ([`DocumentStatus`]).
|
||
pub fn derive_status(paid: Decimal, total: Decimal) -> DocumentStatus {
|
||
if paid.is_zero() {
|
||
DocumentStatus::Open
|
||
} else if paid >= total {
|
||
DocumentStatus::Paid
|
||
} else {
|
||
DocumentStatus::Partial
|
||
}
|
||
}
|
||
|
||
/// One row of an aging report.
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
pub struct AgingRow {
|
||
/// Customer or vendor name.
|
||
pub name: String,
|
||
/// Not yet due.
|
||
pub current: Decimal,
|
||
/// 1-30 days past due.
|
||
pub days_30: Decimal,
|
||
/// 31-60 days past due.
|
||
pub days_60: Decimal,
|
||
/// 61-90 days past due.
|
||
pub days_90: Decimal,
|
||
/// Over 90 days past due.
|
||
pub days_90_plus: Decimal,
|
||
}
|
||
|
||
impl AgingRow {
|
||
/// Total outstanding; refused past exact arithmetic.
|
||
pub fn total(&self) -> Result<Decimal, LedgerError> {
|
||
[self.days_30, self.days_60, self.days_90, self.days_90_plus]
|
||
.into_iter()
|
||
.try_fold(self.current, Decimal::checked_add)
|
||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{}'s aging total", self.name)))
|
||
}
|
||
|
||
/// Add `other`'s buckets to this row's; refused past exact arithmetic.
|
||
pub(crate) fn add(&mut self, other: &AgingRow) -> Result<(), LedgerError> {
|
||
let too_large = || LedgerError::TooLargeToCompute(format!("{}'s aging", self.name));
|
||
let sum = |a: Decimal, b: Decimal| a.checked_add(b).ok_or_else(too_large);
|
||
let (current, days_30, days_60, days_90, days_90_plus) = (
|
||
sum(self.current, other.current)?,
|
||
sum(self.days_30, other.days_30)?,
|
||
sum(self.days_60, other.days_60)?,
|
||
sum(self.days_90, other.days_90)?,
|
||
sum(self.days_90_plus, other.days_90_plus)?,
|
||
);
|
||
(
|
||
self.current,
|
||
self.days_30,
|
||
self.days_60,
|
||
self.days_90,
|
||
self.days_90_plus,
|
||
) = (current, days_30, days_60, days_90, days_90_plus);
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// GOAL: say how overdue a balance is, as of a date.
|
||
/// SOURCE: docs/spec/rules.md B-18 (bucket edges: CONVENTION, ASC
|
||
/// 326-20-55-38 illustrative; due today is Current: DECISION,
|
||
/// owner 2026-10-13).
|
||
/// INVARIANTS:
|
||
/// 1. MUST put the whole balance in exactly one bucket; the others are
|
||
/// zero.
|
||
/// 2. NEVER moves a balance to a younger bucket as `as_of` advances.
|
||
/// SCENARIOS:
|
||
/// 1. IF `as_of` is on or before the due date:
|
||
/// - SHOULD be Current
|
||
/// - NEVER past due
|
||
/// BECAUSE a document due today is not yet overdue (QuickBooks'
|
||
/// convention; owner, 2026-10-13).
|
||
/// 2. IF it is 1 to 30 days past due: SHOULD be 1-30.
|
||
/// 3. IF 31 to 60: SHOULD be 31-60.
|
||
/// 4. IF 61 to 90: SHOULD be 61-90.
|
||
/// 5. IF more than 90: SHOULD be 90+.
|
||
/// BECAUSE these are the standard 30-day aging buckets.
|
||
/// ```
|
||
pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow {
|
||
let mut row = AgingRow {
|
||
name: String::new(),
|
||
current: Decimal::ZERO,
|
||
days_30: Decimal::ZERO,
|
||
days_60: Decimal::ZERO,
|
||
days_90: Decimal::ZERO,
|
||
days_90_plus: Decimal::ZERO,
|
||
};
|
||
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::*;
|
||
use crate::{AccountType, Currency, Ledger, Money, Side};
|
||
|
||
fn arap_ledger() -> Ledger {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
l.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
l
|
||
}
|
||
|
||
#[test]
|
||
fn invoice_lifecycle() {
|
||
let mut l = arap_ledger();
|
||
let cust = l.add_customer("Acme Corp").unwrap();
|
||
let inv = l
|
||
.create_invoice(
|
||
cust,
|
||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||
vec![DocumentLine {
|
||
description: "Widgets".to_string(),
|
||
amount: "1000.00".parse().unwrap(),
|
||
class_id: None,
|
||
account: None,
|
||
quantity: None,
|
||
unit_price: None,
|
||
}],
|
||
)
|
||
.unwrap();
|
||
let invoice = l.get_invoice(inv).unwrap();
|
||
assert_eq!(invoice.total().unwrap(), "1000.00".parse().unwrap());
|
||
assert_eq!(invoice.status, DocumentStatus::Open);
|
||
|
||
// A/R has a debit balance of 1000.
|
||
let ar = l.chart().find_by_code("1200").unwrap().id;
|
||
assert_eq!(l.account_balance(ar).unwrap(), "1000".parse().unwrap());
|
||
|
||
// Partial payment.
|
||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||
l.receive_payment(
|
||
inv,
|
||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||
"400.00".parse().unwrap(),
|
||
cash,
|
||
)
|
||
.unwrap();
|
||
let invoice = l.get_invoice(inv).unwrap();
|
||
assert_eq!(invoice.status, DocumentStatus::Partial);
|
||
assert_eq!(invoice.balance().unwrap(), "600.00".parse().unwrap());
|
||
|
||
// Full payment.
|
||
l.receive_payment(
|
||
inv,
|
||
NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
|
||
"600.00".parse().unwrap(),
|
||
cash,
|
||
)
|
||
.unwrap();
|
||
let invoice = l.get_invoice(inv).unwrap();
|
||
assert_eq!(invoice.status, DocumentStatus::Paid);
|
||
assert_eq!(l.account_balance(ar).unwrap(), Decimal::ZERO);
|
||
}
|
||
|
||
#[test]
|
||
fn bill_lifecycle() {
|
||
let mut l = arap_ledger();
|
||
let vend = l.add_vendor("Supply Co").unwrap();
|
||
let bill = l
|
||
.create_bill(
|
||
vend,
|
||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||
vec![DocumentLine {
|
||
description: "Paper".to_string(),
|
||
amount: "250.00".parse().unwrap(),
|
||
class_id: None,
|
||
account: None,
|
||
quantity: None,
|
||
unit_price: None,
|
||
}],
|
||
)
|
||
.unwrap();
|
||
let ap = l.chart().find_by_code("2000").unwrap().id;
|
||
// A/P is credit-normal; balance should be 250 (credit).
|
||
assert_eq!(l.account_balance(ap).unwrap(), "250".parse().unwrap());
|
||
|
||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||
// Seed cash so the payment doesn't go negative (not required, but tidy).
|
||
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), "seed");
|
||
let m = Money::new("1000.00".parse().unwrap(), Currency::USD);
|
||
l.add_line(e, cash, Side::Debit, m).unwrap();
|
||
let eq = l
|
||
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
l.add_line(e, eq, Side::Credit, m).unwrap();
|
||
l.post_entry(e).unwrap();
|
||
|
||
l.pay_bill(
|
||
bill,
|
||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||
"250.00".parse().unwrap(),
|
||
cash,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(l.get_bill(bill).unwrap().status, DocumentStatus::Paid);
|
||
assert_eq!(l.account_balance(ap).unwrap(), Decimal::ZERO);
|
||
}
|
||
|
||
#[test]
|
||
fn overpayment_rejected() {
|
||
let mut l = arap_ledger();
|
||
let cust = l.add_customer("Acme").unwrap();
|
||
let inv = l
|
||
.create_invoice(
|
||
cust,
|
||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||
vec![DocumentLine {
|
||
description: "X".to_string(),
|
||
amount: "100.00".parse().unwrap(),
|
||
class_id: None,
|
||
account: None,
|
||
quantity: None,
|
||
unit_price: None,
|
||
}],
|
||
)
|
||
.unwrap();
|
||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||
let err = l.receive_payment(
|
||
inv,
|
||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||
"150.00".parse().unwrap(),
|
||
cash,
|
||
);
|
||
assert!(matches!(err, Err(LedgerError::Overpayment(_, _))));
|
||
}
|
||
|
||
#[test]
|
||
fn aging_boundaries() {
|
||
// Spec scenarios 1-5, at every edge.
|
||
let due = NaiveDate::from_ymd_opt(2026, 3, 31).unwrap();
|
||
let b = Decimal::from(7);
|
||
let bucket = |days: i64| {
|
||
let r = aging_bucket(due, due + chrono::Duration::days(days), b);
|
||
[r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus]
|
||
.iter()
|
||
.position(|x| !x.is_zero())
|
||
.unwrap()
|
||
};
|
||
for (days, want) in [
|
||
(-10, 0),
|
||
(0, 0),
|
||
(1, 1),
|
||
(30, 1),
|
||
(31, 2),
|
||
(60, 2),
|
||
(61, 3),
|
||
(90, 3),
|
||
(91, 4),
|
||
(4000, 4),
|
||
] {
|
||
assert_eq!(bucket(days), want, "{days} days past due");
|
||
}
|
||
}
|
||
|
||
proptest::proptest! {
|
||
/// Invariants 1 and 2: one bucket holds the whole balance, and
|
||
/// time only ever ages it.
|
||
#[test]
|
||
fn aging_is_exclusive_and_only_ages(
|
||
due_off in 0i64..2000, a in 0i64..2000, step in 0i64..400, cents in 1i64..10_000_000,
|
||
) {
|
||
let base = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||
let due = base + chrono::Duration::days(due_off);
|
||
let b = Decimal::new(cents, 2);
|
||
let idx = |as_of: NaiveDate| {
|
||
let r = aging_bucket(due, as_of, b);
|
||
let cells = [r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus];
|
||
let held: Vec<usize> = (0..5).filter(|&i| !cells[i].is_zero()).collect();
|
||
proptest::prop_assert_eq!(held.len(), 1);
|
||
proptest::prop_assert_eq!(cells[held[0]], b);
|
||
Ok(held[0])
|
||
};
|
||
let early = base + chrono::Duration::days(a);
|
||
let later = early + chrono::Duration::days(step);
|
||
proptest::prop_assert!(idx(early)? <= idx(later)?);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ar_aging_buckets_correctly() {
|
||
let mut l = arap_ledger();
|
||
let cust = l.add_customer("Acme").unwrap();
|
||
// Due in the future: current.
|
||
l.create_invoice(
|
||
cust,
|
||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||
NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(),
|
||
vec![DocumentLine {
|
||
description: "A".to_string(),
|
||
amount: "100.00".parse().unwrap(),
|
||
class_id: None,
|
||
account: None,
|
||
quantity: None,
|
||
unit_price: None,
|
||
}],
|
||
)
|
||
.unwrap();
|
||
// 45 days past due: 60 bucket.
|
||
l.create_invoice(
|
||
cust,
|
||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(),
|
||
NaiveDate::from_ymd_opt(2026, 8, 17).unwrap(),
|
||
vec![DocumentLine {
|
||
description: "B".to_string(),
|
||
amount: "200.00".parse().unwrap(),
|
||
class_id: None,
|
||
account: None,
|
||
quantity: None,
|
||
unit_price: None,
|
||
}],
|
||
)
|
||
.unwrap();
|
||
let as_of = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
||
let aging = l.ar_aging(as_of).unwrap();
|
||
assert_eq!(aging.len(), 1);
|
||
assert_eq!(aging[0].current, "100".parse().unwrap());
|
||
assert_eq!(aging[0].days_60, "200".parse().unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn invoice_in_closed_period_rejected() {
|
||
let mut l = arap_ledger();
|
||
let cust = l.add_customer("Acme").unwrap();
|
||
let pid = l
|
||
.create_period(
|
||
"2026-10",
|
||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||
)
|
||
.unwrap();
|
||
l.close_period(pid).unwrap();
|
||
let err = l.create_invoice(
|
||
cust,
|
||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||
NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(),
|
||
vec![DocumentLine {
|
||
description: "X".to_string(),
|
||
amount: "100.00".parse().unwrap(),
|
||
class_id: None,
|
||
account: None,
|
||
quantity: None,
|
||
unit_price: None,
|
||
}],
|
||
);
|
||
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
|
||
}
|
||
}
|