diff --git a/bookspec/README.md b/bookspec/README.md index b7ad560..d60edea 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -251,6 +251,11 @@ the side gives the direction. - `instrument` on an event: the payment method (a `setup.instruments` label) the entry was paid with. Neither `parties` nor `instrument` on a reversal, which takes its original's, nor on a command. +- `older = true` on an entry (`lines`): the entry as an earlier version + could write it, which this version's commands refuse: a line by hand + on a receivables or payables account, where only an invoice, a bill or + their payments post now (rules.md B-38, the control guard). Books that + hold one must still read right, so the books take it as replay does. - `close = { start, to, rounding? }` as an event: closes the period from `start` through the event's `date` (rules.md B-11, B-13), `to` `"members"` (split by share, in the order listed) or diff --git a/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml b/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml index c084509..994db64 100644 --- a/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml +++ b/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml @@ -63,6 +63,7 @@ lines = [ [[events]] date = "2026-03-01" description = "Dana Studio pays the company's invoice" +older = true payee = "Dana Studio" lines = [ { account = "1000", side = "debit", amount = "450.00" }, diff --git a/bookspec/examples/p3-party-signs-and-reversals.toml b/bookspec/examples/p3-party-signs-and-reversals.toml index 43c35f2..889a21c 100644 --- a/bookspec/examples/p3-party-signs-and-reversals.toml +++ b/bookspec/examples/p3-party-signs-and-reversals.toml @@ -176,6 +176,7 @@ lines = [ [[events]] date = "2026-10-20" description = "Bill" +older = true parties = [{ party = "Northwind Filings" }] lines = [ { account = "6000", side = "debit", amount = "80.00" }, @@ -192,6 +193,7 @@ reverses = 7 [[events]] date = "2026-10-20" description = "Invoice" +older = true parties = [{ party = "Riley Chen" }] lines = [ { account = "1200", side = "debit", amount = "120.00" }, diff --git a/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml b/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml index 386d961..dfccb7c 100644 --- a/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml +++ b/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml @@ -95,6 +95,7 @@ bill = { vendor = "Dana Illustration", lines = [ [[events]] date = "2026-03-20" description = "Cedar pays invoice 1" +older = true lines = [ { account = "1000", side = "debit", amount = "3800.00" }, { account = "1200", side = "credit", amount = "3800.00" }, @@ -103,6 +104,7 @@ lines = [ [[events]] date = "2026-03-30" description = "Pay Dana's bill" +older = true payee = "Dana Illustration" lines = [ { account = "2000", side = "debit", amount = "1250.00" }, diff --git a/bookspec/examples/projects-net-is-its-tagged-lines.toml b/bookspec/examples/projects-net-is-its-tagged-lines.toml index fb1c60b..d770b0c 100644 --- a/bookspec/examples/projects-net-is-its-tagged-lines.toml +++ b/bookspec/examples/projects-net-is-its-tagged-lines.toml @@ -33,7 +33,7 @@ code = "1000" name = "Cash" type = "asset" [[setup.accounts]] -code = "1200" +code = "1500" name = "Equipment" type = "asset" [[setup.accounts]] @@ -93,7 +93,7 @@ lines = [ date = "2026-03-05" description = "Test devices for Beta" lines = [ - { account = "1200", side = "debit", amount = "900.00", project = "Beta" }, + { account = "1500", side = "debit", amount = "900.00", project = "Beta" }, { account = "1000", side = "credit", amount = "900.00" }, ] [[events]] diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index f00c018..47a39db 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -4,8 +4,16 @@ //! //! * `create_invoice`: Dr `1200` Accounts Receivable, Cr `4000` Revenue. //! * `create_bill`: Dr `6999` General Expenses, Cr `2000` Accounts Payable. -//! * `receive_payment`: Dr Cash, Cr A/R. Updates the invoice's paid amount. -//! * `pay_bill`: Dr A/P, Cr Cash. Updates the bill's paid amount. +//! * `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 @@ -71,7 +79,10 @@ impl Vendor { } } -/// Payment status of an invoice or bill. +/// 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. @@ -82,6 +93,320 @@ pub enum DocumentStatus { Paid, } +/// 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. +/// Only `Origin` and `Payment` are written today (P4b S1); the rest are +/// the movements later slices write (P4b.6), named here so a tag's kind +/// never changes meaning. +#[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 (S7). + Adopted, + /// The balance moved with its control role (S7). + 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, + /// 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, +} + +/// 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, + /// The reference, when there is one: one line of text. + pub reference: Option, +} + +/// 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 line on an invoice or bill. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct DocumentLine { @@ -111,9 +436,22 @@ pub struct Invoice { pub due_date: NaiveDate, /// Line items. pub lines: Vec, - /// How much has been paid. + /// 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, - /// Derived from amount_paid vs total. + /// 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, @@ -168,26 +506,18 @@ pub fn document_parts( .collect() } -/// What is still owed on a document: its total less what was paid. -fn document_balance(total: Decimal, paid: Decimal, what: &str) -> Result { - total - .checked_sub(paid) - .ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s balance"))) -} - impl Invoice { /// Total of all lines ([`document_total`]). pub fn total(&self) -> Result { document_total(&self.lines, &format!("invoice {}", self.id)) } - /// Amount still owed. + /// 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 { - document_balance( - self.total()?, - self.amount_paid, - &format!("invoice {}", self.id), - ) + self.total()?; + Ok(self.owed) } } @@ -204,9 +534,22 @@ pub struct Bill { pub due_date: NaiveDate, /// Line items. pub lines: Vec, - /// How much has been paid. + /// 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, - /// Derived from amount_paid vs total. + /// 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, @@ -218,13 +561,12 @@ impl Bill { document_total(&self.lines, &format!("bill {}", self.id)) } - /// Amount still owed. + /// 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 { - document_balance( - self.total()?, - self.amount_paid, - &format!("bill {}", self.id), - ) + self.total()?; + Ok(self.owed) } } @@ -263,7 +605,8 @@ pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), Led Ok(()) } -/// Derive status from paid vs total. +/// 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 diff --git a/crates/ledger-core/src/cashflow.rs b/crates/ledger-core/src/cashflow.rs index 099b3a2..0c4b52c 100644 --- a/crates/ledger-core/src/cashflow.rs +++ b/crates/ledger-core/src/cashflow.rs @@ -1030,7 +1030,8 @@ type KindOrder = (u8, u8, String); /// that part, equipment first, to its payables lines and to its payments, /// so the payment is an investing outflow (ASC 230-10-45-13(c)). A bill /// posts to the general-expenses control today, so this is empty until a -/// bill can carry an equipment line. +/// bill can carry an equipment line (P4b S4). A bill's payments are the +/// entries whose payables line carries it (rules.md B-38). #[derive(Default)] struct BillSplits { /// By (entry, line index): the investing part of that payables line, @@ -1077,15 +1078,20 @@ fn bill_splits(roles: &Roles<'_>, chains: &Chains) -> Result, chains: &Chains) -> Result(revenue: T, expenses: T) -> Option { Some(if gap > T::ZERO { gap } else { T::ZERO }) } +/// Where a document's running balance first leaves `0 ..= total`, if it +/// ever does ([`running_bounds`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Bounds { + /// Every running balance is within `0 ..= total`. + Within, + /// After step `i` (0-based) the balance is below zero: more paid than + /// the document is for, or a void under a payment that stands. + Below(usize), + /// After step `i` the balance is above the total: more owed than the + /// document is for (a payment's reversal re-instating its document's + /// origin beside another). + Above(usize), + /// A running balance can't be represented exactly. + Overflow, +} + +/// A document's bounds over time: `deltas` are what each date moves its +/// outstanding balance by, in date order (one per date, that date's +/// movements summed), starting from nothing owed. +/// +/// ```text +/// GOAL: one definition of "a document is never overpaid and never owes +/// more than it is for", at every date, not only at the end. +/// SOURCE: docs/spec/rules.md B-38 INVARIANT 2 (0 <= outstanding(d, D) +/// <= total(d) at every D); plan/served.md P4b.3 (Bounds). +/// INVARIANTS: +/// 1. Within exactly when every prefix sum of `deltas` is within +/// 0 ..= total; else the first prefix that isn't: Below when it is +/// under zero, Above when it is over the total. +/// 2. NEVER wraps: Overflow when a prefix sum can't be represented. +/// 3. Empty `deltas` is Within (nothing owed, nothing paid). +/// ``` +pub fn running_bounds(total: T, deltas: &[T]) -> Bounds { + let mut running = T::ZERO; + for (i, &d) in deltas.iter().enumerate() { + running = match running.checked_add(d) { + Some(r) => r, + None => return Bounds::Overflow, + }; + if running < T::ZERO { + return Bounds::Below(i); + } + if running > total { + return Bounds::Above(i); + } + } + Bounds::Within +} + +/// How far a running balance is outside `0 ..= total` (zero inside), and +/// whether below; `None` when that can't be represented. +fn out_of_bounds(total: T, running: T) -> Option<(T, bool)> { + if running < T::ZERO { + Some((running.checked_neg()?, true)) + } else if running > total { + Some((running.checked_sub(total)?, false)) + } else { + Some((T::ZERO, false)) + } +} + +/// Whether moving a document from `before` to `after` keeps its bounds: +/// both are its movements by date over the same dates (a date with +/// nothing moving is zero). At every date, `after` must be within +/// `0 ..= total`, or no further outside than `before` was there. +/// +/// ```text +/// GOAL: a command never puts a document out of bounds, yet books an +/// earlier version left out of bounds (a void under a payment that +/// stands) take the step that mends them. +/// SOURCE: docs/spec/rules.md B-38 INVARIANT 2, B-28 (replay accepts what +/// was written; the bound is the commands'); plan/served.md +/// P4b.14 item 3 (the remedy: reverse the payment). +/// INVARIANTS: +/// 1. When `before` is within bounds at every date (running_bounds is +/// Within), the result is running_bounds(total, after): the +/// definition exactly. +/// 2. Within means no prefix of `after` is further outside 0 ..= total +/// than the same prefix of `before`; else the first prefix that is, +/// Below or Above by where it went. Only the distance counts, not +/// the side: books out of bounds below (overpaid by 5) may take a +/// step that leaves them above by 3 (over-owed), nearer the range. +/// Intended: any step that mends is taken, and these commands +/// never write a book out of bounds in the first place (INVARIANT 1). +/// 3. NEVER wraps: Overflow when a prefix can't be represented. +/// 4. Slices of different lengths: the missing movements are zero. +/// ``` +pub fn bounds_kept(total: T, before: &[T], after: &[T]) -> Bounds { + let (mut was, mut now) = (T::ZERO, T::ZERO); + for i in 0..before.len().max(after.len()) { + let step = |r: T, d: Option<&T>| r.checked_add(d.copied().unwrap_or(T::ZERO)); + let (Some(w), Some(n)) = (step(was, before.get(i)), step(now, after.get(i))) else { + return Bounds::Overflow; + }; + (was, now) = (w, n); + let (Some((by_was, _)), Some((by_now, below))) = + (out_of_bounds(total, was), out_of_bounds(total, now)) + else { + return Bounds::Overflow; + }; + if by_now > by_was { + return if below { + Bounds::Below(i) + } else { + Bounds::Above(i) + }; + } + } + Bounds::Within +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index e84b3c3..eaf1a68 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -263,10 +263,30 @@ pub struct Ledger { next_instrument_id: crate::InstrumentId, /// The payment method each entry was paid with, for 1099 reporting. entry_instruments: HashMap, - /// Each invoice's payments (entry, amount), for aging as of a date. - invoice_payments: HashMap>, - /// Each bill's payments (entry, amount), for aging as of a date. - bill_payments: HashMap>, + /// The document each receivables or payables line belongs to, by + /// (entry, line index) (rules.md B-38, plan P4b.3): written by the + /// document commands in the step that posts the line, derived on + /// replay from the document events, and copied onto a reversal's + /// mirror line. Every figure about a document is read from these. + line_docs: std::collections::BTreeMap<(EntryId, usize), arap::DocTag>, + /// Every payment event recorded against a document, each amount once, + /// whether its entry stands and whether its line could be tagged: + /// what a version before document standing counts as paid + /// (`payments_recorded`; plan P4b.7, `document_standing`). + payment_events: std::collections::BTreeMap>, + /// Indexes derived from the fields above, kept as they change so the + /// figures read from them stay linear (none is state of its own): + /// each document's tagged lines (`line_docs` by document)... + doc_index: + std::collections::BTreeMap>, + /// ... the accounts holding a document's line, and whether an + /// invoice's (receivables) or a bill's (payables)... + doc_accounts: std::collections::BTreeSet<(AccountId, bool)>, + /// ... each posted entry's place in `order`... + order_pos: HashMap, + /// ... and the posted entry reversing each reversed entry (the first + /// posted, as `reversal_chain` reads it). + reversed_by: HashMap, /// Control roles assigned an account ([`Ledger::assign_role`]), by /// [`ControlAccount::key`]. A role not here uses its code. roles: std::collections::BTreeMap<&'static str, AccountId>, @@ -329,8 +349,12 @@ impl Ledger { instruments: std::collections::BTreeMap::new(), next_instrument_id: 1, entry_instruments: HashMap::new(), - invoice_payments: HashMap::new(), - bill_payments: HashMap::new(), + line_docs: std::collections::BTreeMap::new(), + payment_events: std::collections::BTreeMap::new(), + doc_index: std::collections::BTreeMap::new(), + doc_accounts: std::collections::BTreeSet::new(), + order_pos: HashMap::new(), + reversed_by: HashMap::new(), roles: std::collections::BTreeMap::new(), asset_tags: std::collections::BTreeMap::new(), asset_runs: std::collections::BTreeMap::new(), @@ -531,9 +555,22 @@ impl Ledger { Ok(entry) } - /// Register a recurring entry. Returns its id. + /// Register a recurring entry. Returns its id. A template naming a + /// receivables or payables account is refused (UntaggedControlLine, + /// the control guard: plan P4b.3), since every draft it made would be. pub fn register_recurring(&mut self, new: NewRecurring) -> Result { schedules::validate_recurring(&new, &self.chart)?; + if let Some((i, (account, ..))) = new + .lines + .iter() + .enumerate() + .find(|(_, (a, ..))| self.control_role_of(*a).is_some()) + { + return Err(LedgerError::UntaggedControlLine { + account: self.account_label(*account), + at: format!("line {i} of the recurring template"), + }); + } self.insert_recurring(new) } @@ -723,11 +760,11 @@ impl Ledger { "entry_instruments", self.entry_instruments == other.entry_instruments, ), + ("line_docs", self.line_docs == other.line_docs), ( - "invoice_payments", - self.invoice_payments == other.invoice_payments, + "payment_events", + self.payment_events == other.payment_events, ), - ("bill_payments", self.bill_payments == other.bill_payments), ("roles", self.roles == other.roles), ("asset_tags", self.asset_tags == other.asset_tags), ("asset_runs", self.asset_runs == other.asset_runs), @@ -1245,8 +1282,23 @@ impl Ledger { /// controls (open invoices, bills, taxes withheld); move the /// balance with an entry first. Revenue and expense roles move /// freely. + /// - For receivables and payables: DocumentRoleHoldsBalance + /// instead, which names no hand entry (the control guard + /// refuses one, B-38): their balance moves with its documents, + /// a later command (plan P4b S7). Only on their control account + /// (Self::control_role_of): the account at the default code, + /// before any document posted there, is the chart's own, and + /// its balance no document's. /// 5. IF the role already resolves to that account: SHOULD accept /// (the assignment is recorded). + /// 6. IF the role is receivables or payables and the account it + /// would move to holds a posted line that is no document's: + /// - SHOULD refuse (RoleAccountHasHandLines), naming the entry + /// - NEVER make a hand line a control account's + /// BECAUSE no command adds an untagged control line (B-38 + /// INVARIANT 1). Replay never refuses (B-28). + /// An account the role moves off keeps its documents' lines, and + /// stays guarded while it holds them (Self::control_role_of). /// ``` pub fn assign_role( &mut self, @@ -1295,14 +1347,53 @@ impl Ledger { } } let now = self.role_account(c); + let documents = c.key == RECEIVABLES.key || c.key == PAYABLES.key; + // SCENARIO 6: the new account's lines must all be documents'. + if documents + && !crate::parties::is_replaying() + && let Some(a) = target + && now != Some(a.id) + && let Some(entry) = self.posted_entries().find_map(|e| { + e.lines() + .iter() + .enumerate() + .any(|(i, l)| l.account == a.id && !self.line_docs.contains_key(&(e.id(), i))) + .then_some(e.id()) + }) + { + return Err(LedgerError::RoleAccountHasHandLines { + role: c.role.to_string(), + code: a.code.clone(), + name: a.name.clone(), + entry, + }); + } let balance_sheet = !matches!(c.account_type, AccountType::Revenue | AccountType::Expense); + // A document role's balance is its documents' only on its control + // account (Self::control_role_of): the account at its default + // code before any document posted there is a chart's own. + let controls = |current: AccountId| { + !documents + || self + .control_role_of(current) + .is_some_and(|r| r.key == c.key) + }; if let Some(current) = now && balance_sheet && now != target.map(|a| a.id) + && controls(current) { let balance = self.account_balance(current).unwrap_or(Decimal::ZERO); if !balance.is_zero() { let a = self.chart.get(current).expect("a role's account exists"); + if documents { + return Err(LedgerError::DocumentRoleHoldsBalance { + role: c.role.to_string(), + code: a.code.clone(), + name: a.name.clone(), + balance, + }); + } return Err(LedgerError::RoleHoldsBalance { role: c.role.to_string(), code: a.code.clone(), @@ -1448,6 +1539,7 @@ impl Ledger { bounded: bool, ) -> Result<(), LedgerError> { self.check_line_account(account)?; + self.check_hand_line(entry, account)?; check_minor_units(amount)?; if bounded { check_line_size(amount.amount)?; @@ -1455,6 +1547,165 @@ impl Ledger { self.replay_add_line(entry, account, side, amount) } + /// The document role an account is the control account of, if any: + /// receivables or payables (rules.md B-23, B-38; a later control role + /// joins them). An account is one when the role is assigned to it + /// ([`Self::assign_role`]), or when it holds a line of one of the + /// role's documents (the role's default code, once a document has + /// posted there; an account the role has moved off, whose documents' + /// lines stay on it). + /// + /// ```text + /// GOAL: guard exactly the accounts whose balance is documents, and + /// no other: a chart's own account at a role's default code + /// (1200 Equipment, a card at 2000) is anyone's until a document + /// posts there. + /// SOURCE: docs/spec/rules.md B-23, B-38 INVARIANT 1. + /// INVARIANTS: + /// 1. Some(role) when the role is assigned to the account, or the + /// account holds a line tagged with one of the role's documents + /// (an invoice's for receivables, a bill's for payables). + /// 2. None for every other account, the default code's included. + /// ``` + pub fn control_role_of(&self, account: AccountId) -> Option<&'static ControlAccount> { + [(&RECEIVABLES, true), (&PAYABLES, false)] + .into_iter() + .find(|&(c, invoices)| { + self.roles.get(c.key) == Some(&account) + || self.doc_accounts.contains(&(account, invoices)) + }) + .map(|(c, _)| c) + } + + /// The accounts that are a document role's control accounts + /// ([`Self::control_role_of`]), in account order. + fn document_role_accounts(&self, c: &ControlAccount) -> Vec { + let invoices = c.key == RECEIVABLES.key; + let mut out: Vec = self + .doc_accounts + .iter() + .filter(|&&(_, i)| i == invoices) + .map(|&(a, _)| a) + .collect(); + if let Some(&a) = self.roles.get(c.key) + && !out.contains(&a) + { + out.push(a); + out.sort_unstable(); + } + out + } + + /// Refuse a line by hand on a control account (the control guard, + /// plan P4b.3): such a line is the document commands' alone, so the + /// account always equals its documents. Replay never refuses (B-28). + fn check_hand_line(&self, entry: EntryId, account: AccountId) -> Result<(), LedgerError> { + if crate::parties::is_replaying() || self.control_role_of(account).is_none() { + return Ok(()); + } + let line = self.entries.get(&entry).map_or(0, |e| e.lines().len()); + Err(LedgerError::UntaggedControlLine { + account: self.account_label(account), + at: format!("line {line} of entry {entry}"), + }) + } + + /// An account as a refusal names it: `1200 "Accounts Receivable"`. + fn account_label(&self, account: AccountId) -> String { + self.chart.get(account).map_or_else( + || account.to_string(), + |a| format!("{} \"{}\"", a.code, a.name), + ) + } + + /// A document command's line on a control account: [`Self::add_line`]'s + /// checks but the control guard's, then tagged with its document in + /// the same step (so the guard at the post finds it tagged). + fn add_tagged_line( + &mut self, + entry: EntryId, + account: AccountId, + side: Side, + amount: Money, + tag: arap::DocTag, + ) -> Result<(), LedgerError> { + self.check_line_account(account)?; + check_minor_units(amount)?; + check_line_size(amount.amount)?; + self.replay_add_line(entry, account, side, amount)?; + let idx = self.entries[&entry].lines().len() - 1; + self.tag_line(entry, idx, tag); + Ok(()) + } + + /// Tag a ledger line with its document, keeping the indexes read from + /// the tags (`doc_index`, `doc_accounts`) in step. The only way a tag + /// is written; tags are never removed (a refused command's are rolled + /// back with the rest of the ledger by [`Self::atomically`]). + fn tag_line(&mut self, entry: EntryId, idx: usize, tag: arap::DocTag) { + if let Some(l) = self.entries.get(&entry).and_then(|e| e.lines().get(idx)) { + self.doc_accounts + .insert((l.account, matches!(tag.doc, arap::DocRef::Invoice(_)))); + } + self.doc_index + .entry(tag.doc) + .or_default() + .insert((entry, idx)); + self.line_docs.insert((entry, idx), tag); + } + + /// The control guard at the post (plan P4b.3): every line of `entry` on + /// a receivables or payables account carries its document, but the + /// mirror of an untagged line in a reversal that takes it off the + /// books (reversing an old hand line is how it is cleaned up). + /// Replay never refuses (B-28). + /// + /// ```text + /// GOAL: no command adds a line on a control account that is no + /// document's; one only takes a legacy one away. + /// SOURCE: docs/spec/rules.md B-38 INVARIANT 1 and item 3 (the + /// exemption). + /// INVARIANTS: + /// 1. A tagged line passes. + /// 2. An untagged line passes only as the mirror of an untagged line + /// on the same account in the entry it reverses, when that entry + /// stands at an even place in its reversal chain (the hand line + /// itself, or a reversal of its reversal): the mirror removes the + /// line. Reversing the hand line's reversal would put it back, and + /// is refused. + /// ``` + fn check_control_lines(&self, entry: &JournalEntry) -> Result<(), LedgerError> { + if crate::parties::is_replaying() { + return Ok(()); + } + for (i, l) in entry.lines().iter().enumerate() { + if self.control_role_of(l.account).is_none() + || self.line_docs.contains_key(&(entry.id(), i)) + { + continue; + } + let removes_untagged = entry.reversal_of().is_some_and(|o| { + self.entries + .get(&o) + .and_then(|o| o.lines().get(i)) + .is_some_and(|ol| ol.account == l.account) + && !self.line_docs.contains_key(&(o, i)) + && self + .reversal_chain(o) + .iter() + .position(|&e| e == o) + .is_some_and(|at| at % 2 == 0) + }); + if !removes_untagged { + return Err(LedgerError::UntaggedControlLine { + account: self.account_label(l.account), + at: format!("line {i} of entry {}", entry.id()), + }); + } + } + Ok(()) + } + /// A line's account must exist and take postings, checked when the /// line is added (owner, 2026-10-13); posting checks again. fn check_line_account(&self, account: AccountId) -> Result<(), LedgerError> { @@ -1528,6 +1779,7 @@ impl Ledger { fx_rate: Decimal, ) -> Result<(), LedgerError> { self.check_line_account(account)?; + self.check_hand_line(entry, account)?; if foreign_amount <= Decimal::ZERO { return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount)); } @@ -1624,12 +1876,24 @@ impl Ledger { /// BECAUSE a ledger in one money holds no fractions of its /// smallest unit (B-3); found when a payroll run posted $65.27794 /// (the store's refusal property in CI, 2026-10-13). + /// 6. IF a line is on a receivables or payables account (the role's + /// account now) and carries no document: + /// - SHOULD refuse (UntaggedControlLine), naming the document + /// tools + /// - NEVER refuse the mirror of an untagged line in a reversal + /// BECAUSE a control account must equal its documents (B-23, + /// B-38): only the document commands post there, and reversing + /// an old hand line is how it is cleaned up. Replay never + /// refuses (B-28). /// ``` pub fn post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> { if let Some(e) = self.entries.get(&entry) { for line in e.lines() { check_minor_units(line.amount)?; } + if e.status() == EntryStatus::Draft { + self.check_control_lines(e)?; + } } self.replay_post_entry(entry) } @@ -1656,7 +1920,11 @@ impl Ledger { .get_mut(&entry) .ok_or(LedgerError::UnknownEntry(entry))? .mark_posted(); + self.order_pos.insert(entry, self.order.len()); self.order.push(entry); + if let Some(o) = snapshot.reversal_of() { + self.reversed_by.entry(o).or_insert(entry); + } Ok(()) } @@ -1700,6 +1968,22 @@ impl Ledger { /// - NEVER recognize one month of a schedule twice /// BECAUSE a reversed month is due again and may have been run /// again (P3 depreciation review R3). + /// 6. IF the entry carries a document's lines (an invoice's or a + /// bill's own entry, a payment, or a reversal of one) and `date` + /// is before the entry's own date: + /// - SHOULD refuse (DocumentReversalBeforeOriginal) + /// BECAUSE a correction comes after what it corrects (B-9; plan + /// P4b.3, "Dating rules"). + /// 7. IF reversing it would leave a document it carries owing less + /// than nothing or more than its total at some date (a void + /// under a payment that stands; re-instating a payment beside + /// another), where it didn't before: + /// - SHOULD refuse (DocumentBounds), naming the entries to + /// reverse first + /// - NEVER refuse reversing a payment, which only reopens its + /// document + /// BECAUSE 0 <= outstanding <= total at every date (B-38 + /// INVARIANT 2). Replay never refuses (B-28). /// ``` pub fn check_reversal(&self, entry: EntryId, date: NaiveDate) -> Result<(), LedgerError> { let original = self @@ -1709,11 +1993,8 @@ impl Ledger { if original.status() != EntryStatus::Posted { return Err(LedgerError::EntryNotPosted(entry)); } - if let Some(by) = self - .posted_entries() - .find(|e| e.reversal_of() == Some(entry)) - { - return Err(LedgerError::EntryAlreadyReversed(entry, by.id())); + if let Some(&by) = self.reversed_by.get(&entry) { + return Err(LedgerError::EntryAlreadyReversed(entry, by)); } if self.closing_entry_ids().contains(&entry) { return Err(LedgerError::ClosingEntryIrreversible(entry)); @@ -1735,6 +2016,44 @@ impl Ledger { s.id ))); } + if !crate::parties::is_replaying() { + self.check_document_reversal(original, date)?; + } + Ok(()) + } + + /// [`Self::check_reversal`] SCENARIOS 6 and 7: a reversal of an entry + /// carrying documents' lines. + fn check_document_reversal( + &self, + original: &JournalEntry, + date: NaiveDate, + ) -> Result<(), LedgerError> { + let mut moves: Vec<(arap::DocRef, Decimal)> = Vec::new(); + for (i, l) in original.lines().iter().enumerate() { + if let Some(tag) = self.line_docs.get(&(original.id(), i)) { + // The mirror moves the document by the opposite. + let by = -tag.doc.counted(l.side, l.amount.amount); + match moves.iter_mut().find(|(d, _)| *d == tag.doc) { + Some((_, m)) => *m += by, + None => moves.push((tag.doc, by)), + } + } + } + let Some(&(first, _)) = moves.first() else { + return Ok(()); + }; + if date < original.date() { + return Err(LedgerError::DocumentReversalBeforeOriginal { + entry: original.id(), + document: first.to_string(), + original: original.date(), + date, + }); + } + for (doc, by) in moves { + self.check_item_bounds(doc, &[(date, by)])?; + } Ok(()) } @@ -1759,18 +2078,51 @@ impl Ledger { /// reversal re-instates it). /// ``` pub fn in_effect(&self, entry: EntryId) -> bool { + self.stands_on(entry, NaiveDate::MAX) + } + + /// Whether a posted entry's effect stands as of a day: it is dated on + /// or before `as_of`, and of the reversals after it in its chain, an + /// even number are dated on or before `as_of` (none, or one reversed + /// again, and so on). A draft or an unknown id stands for nothing. + /// + /// ```text + /// GOAL: say whether an entry counts on a given day, so a document's + /// status (a void) and its aging are right as of any date. + /// SOURCE: docs/spec/rules.md B-9, B-10, B-38; plan/served.md P4b.3 + /// ("Standing as of a date"). + /// INVARIANTS: + /// 1. stands_on(e, NaiveDate::MAX) is in_effect(e). + /// 2. NEVER true for an entry dated after `as_of`, a draft, or an + /// unknown id. + /// 3. A reversal dated after `as_of` doesn't count against it, nor + /// does any later link of its chain (a re-reversal an older + /// journal dated before the reversal it undoes counts from that + /// reversal's date). + /// SCENARIOS: + /// 1. IF a payment is reversed on the 25th: SHOULD stand as of the + /// 22nd, and not as of the 31st. + /// ``` + pub fn stands_on(&self, entry: EntryId, as_of: NaiveDate) -> bool { if self .entries .get(&entry) - .is_none_or(|e| e.status() != EntryStatus::Posted) + .is_none_or(|e| e.status() != EntryStatus::Posted || e.date() > as_of) { return false; } let chain = self.reversal_chain(entry); - chain + let Some(i) = chain.iter().position(|&e| e == entry) else { + return false; + }; + // A reversal counts only once what it reverses counts: an older + // journal may date a re-reversal before the reversal it undoes, + // and it can't undo what hasn't happened yet. + chain[i + 1..] .iter() - .position(|&e| e == entry) - .is_some_and(|i| (chain.len() - 1 - i).is_multiple_of(2)) + .take_while(|r| self.entries.get(r).is_some_and(|r| r.date() <= as_of)) + .count() + .is_multiple_of(2) } /// The schedule and month an entry posted, if it posted one (a @@ -1801,6 +2153,19 @@ impl Ledger { self.atomically(|l| l.reverse_entry_steps(entry, date, description)) } + /// [`Self::reverse_entry`] as a journal recorded it: without the + /// checks later versions added for documents (a reversal's date, a + /// document's bounds, the control guard), for replaying journals + /// written before them (rules.md B-28). Never for new work. + pub fn replay_reverse_entry( + &mut self, + entry: EntryId, + date: NaiveDate, + description: &str, + ) -> Result { + crate::parties::replaying(|| self.reverse_entry(entry, date, description)) + } + /// The steps of [`Self::reverse_entry`], run inside [`Self::atomically`]. fn reverse_entry_steps( &mut self, @@ -1816,12 +2181,20 @@ impl Ledger { .clone(); let id = self.create_entry(date, description); for (idx, line) in original.lines().iter().enumerate() { - self.add_line(id, line.account, kernel::flip(line.side), line.amount)?; + // The mirror of each line, whatever its account: the control + // guard at the post below exempts the mirror of an untagged + // control line, and a tagged one carries its tag. + self.check_line_account(line.account)?; + self.replay_add_line(id, line.account, kernel::flip(line.side), line.amount)?; // The reversal offsets the original line for line: carry the - // class so divisional P&L nets correctly. + // class so divisional P&L nets correctly, and the document so + // it moves by exactly the opposite (B-38 INVARIANT 3). if line.class_id.is_some() { self.set_line_class(id, idx, line.class_id)?; } + if let Some(tag) = self.line_docs.get(&(entry, idx)).copied() { + self.tag_line(id, idx, tag); + } } self.entries .get_mut(&id) @@ -1860,6 +2233,7 @@ impl Ledger { s.posted.remove(&month); } } + self.refresh_docs_of(id); Ok(id) } @@ -4014,11 +4388,7 @@ impl Ledger { } let mut chain = vec![top]; let mut cur = top; - while let Some(r) = self - .posted_entries() - .find(|e| e.reversal_of() == Some(cur)) - .map(JournalEntry::id) - { + while let Some(&r) = self.reversed_by.get(&cur) { if chain.contains(&r) { break; } @@ -4931,14 +5301,15 @@ impl Ledger { /// GOAL: bill a customer and recognize the revenue (accrual basis). /// SOURCE: IRS Pub. 538, accrual method (docs/spec/sources/irs-periods-and-methods.md: /// "report income in the year it is earned"); ASC 606-10-25-23; - /// control accounts: Ledger::check_controls. + /// control accounts: Ledger::check_controls; rules.md B-38. /// INVARIANTS: /// 1. MUST post one balanced entry: Dr 1200 receivables, Cr 4000 revenue, /// for the sum of the lines, dated the invoice date: one revenue /// line per project the lines name, the untagged lines' as one /// (arap::document_parts), each tagged with its project; the /// receivable line never (rules.md B-37 item 8). - /// 2. The invoice MUST start open, with nothing paid. + /// 2. The receivable line MUST carry the invoice (Origin), in the + /// step that posts it: the invoice starts Open, owing its total. /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the customer is unknown, a line amount isn't positive, the due @@ -4972,21 +5343,25 @@ impl Ledger { if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } - let total: Decimal = lines.iter().map(|l| l.amount).sum(); + let total = arap::document_total(&lines, "the invoice")?; let parts = arap::document_parts(&lines, "the invoice")?; self.check_controls(&[RECEIVABLES, REVENUE])?; let ar = self.ensure_control(&RECEIVABLES); let revenue = self.ensure_control(&REVENUE); - let entry = self.create_entry(date, &format!("Invoice {}", self.next_invoice_id)); - self.add_line( + let id = self.next_invoice_id; + let entry = self.create_entry(date, &format!("Invoice {id}")); + self.add_tagged_line( entry, ar, Side::Debit, Money::new(total, self.base_currency), + arap::DocTag { + doc: arap::DocRef::Invoice(id), + movement: arap::Movement::Origin, + }, )?; self.add_document_parts(entry, revenue, Side::Credit, &parts)?; self.post_entry(entry)?; - let id = self.next_invoice_id; self.next_invoice_id += 1; self.invoices.insert( id, @@ -4997,11 +5372,13 @@ impl Ledger { due_date, lines, amount_paid: Decimal::ZERO, + owed: Decimal::ZERO, status: DocumentStatus::Open, entry_id: entry, }, ); self.tag_customer(entry, customer_id); + self.refresh_doc(arap::DocRef::Invoice(id)); Ok(id) } @@ -5012,7 +5389,8 @@ impl Ledger { /// ```text /// GOAL: record a vendor's bill as an expense owed (accrual basis). /// SOURCE: IRS Pub. 538, accrual method ("deduct ... expenses in the year - /// incurred"); control accounts: Ledger::check_controls. + /// incurred"); control accounts: Ledger::check_controls; + /// rules.md B-38. /// INVARIANTS: /// 1. MUST post one balanced entry: Dr 6999 general expenses, Cr 2000 /// payables, for the sum of the lines, dated the bill date, tagged @@ -5020,7 +5398,9 @@ impl Ledger { /// name, the untagged lines' as one (arap::document_parts), each /// tagged with its project; the payable line never (rules.md B-37 /// item 8). - /// 2. A refusal MUST leave the books unchanged. + /// 2. The payable line MUST carry the bill (Origin), in the step that + /// posts it. + /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: /// 1. IF the vendor is unknown, a line amount isn't positive, the due date /// is before the bill date, a line names a project the books don't @@ -5052,21 +5432,25 @@ impl Ledger { if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } - let total: Decimal = lines.iter().map(|l| l.amount).sum(); + let total = arap::document_total(&lines, "the bill")?; let parts = arap::document_parts(&lines, "the bill")?; self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?; let expense = self.ensure_control(&GENERAL_EXPENSES); let ap = self.ensure_control(&PAYABLES); - let entry = self.create_entry(date, &format!("Bill {}", self.next_bill_id)); + let id = self.next_bill_id; + let entry = self.create_entry(date, &format!("Bill {id}")); self.add_document_parts(entry, expense, Side::Debit, &parts)?; - self.add_line( + self.add_tagged_line( entry, ap, Side::Credit, Money::new(total, self.base_currency), + arap::DocTag { + doc: arap::DocRef::Bill(id), + movement: arap::Movement::Origin, + }, )?; self.post_entry(entry)?; - let id = self.next_bill_id; self.next_bill_id += 1; self.bills.insert( id, @@ -5077,11 +5461,13 @@ impl Ledger { due_date, lines, amount_paid: Decimal::ZERO, + owed: Decimal::ZERO, status: DocumentStatus::Open, entry_id: entry, }, ); self.tag_vendor(entry, vendor_id); + self.refresh_doc(arap::DocRef::Bill(id)); Ok(id) } @@ -5109,32 +5495,355 @@ impl Ledger { v } - /// An invoice's payments: `(payment entry, amount)`, in order received. - pub fn invoice_payments(&self, id: InvoiceId) -> &[(EntryId, Decimal)] { - self.invoice_payments.get(&id).map_or(&[], Vec::as_slice) + /// Every document: the invoices by id, then the bills by id. + pub fn documents(&self) -> Vec { + self.invoices() + .into_iter() + .map(|i| arap::DocRef::Invoice(i.id)) + .chain(self.bills().into_iter().map(|b| arap::DocRef::Bill(b.id))) + .collect() } - /// A bill's payments: `(payment entry, amount)`, in order paid. - pub fn bill_payments(&self, id: BillId) -> &[(EntryId, Decimal)] { - self.bill_payments.get(&id).map_or(&[], Vec::as_slice) + /// The document a ledger line belongs to, if it carries one (rules.md + /// B-38): `(entry, line index)`. + pub fn line_doc(&self, entry: EntryId, line: usize) -> Option { + self.line_docs.get(&(entry, line)).copied() + } + + /// A document's date, due date, own entry and total; refused for an + /// unknown document (UnknownInvoice, UnknownBill) or a total past + /// exact arithmetic. + fn doc_header( + &self, + doc: arap::DocRef, + ) -> Result<(NaiveDate, NaiveDate, EntryId, Decimal), LedgerError> { + match doc { + arap::DocRef::Invoice(id) => { + let i = self + .invoices + .get(&id) + .ok_or(LedgerError::UnknownInvoice(id))?; + Ok((i.date, i.due_date, i.entry_id, i.total()?)) + } + arap::DocRef::Bill(id) => { + let b = self.bills.get(&id).ok_or(LedgerError::UnknownBill(id))?; + Ok((b.date, b.due_date, b.entry_id, b.total()?)) + } + } + } + + /// Every posted ledger line tagged with `doc`, in date order (posting + /// order within a day): its origin, payments, and the mirrors of any + /// of them reversed. + pub fn doc_lines(&self, doc: arap::DocRef) -> Vec { + let Some(tagged) = self.doc_index.get(&doc) else { + return Vec::new(); + }; + let mut out: Vec = tagged + .iter() + .filter_map(|&(entry, line)| { + let e = self.entries.get(&entry)?; + if e.status() != EntryStatus::Posted { + return None; + } + let l = e.lines().get(line)?; + Some(arap::DocLine { + entry, + line, + date: e.date(), + account: l.account, + movement: self.line_docs.get(&(entry, line))?.movement, + amount: doc.counted(l.side, l.amount.amount), + reversal: e.reversal_of().is_some(), + }) + }) + .collect(); + out.sort_by_key(|l| (l.date, self.order_pos.get(&l.entry).copied(), l.line)); + out + } + + /// What a document is as of a day, read from the ledger lines tagged + /// with it (rules.md B-38; plan P4b.3, "Derived figures"). `None` for + /// a document dated after `as_of` (it wasn't issued yet). + /// + /// ```text + /// GOAL: every figure about a document (paid, outstanding, status, + /// overdue) is read from its tagged lines, so a reversal, a void + /// or a bounced payment is right by construction. + /// SOURCE: docs/spec/rules.md B-38 (INVARIANTS 1-4), B-9, B-10, B-18; + /// ASC 310-10 (receivables), ASC 405-20-40-1 (payables). + /// INVARIANTS: + /// 1. outstanding = the signed sum of every posted line tagged with + /// it dated on or before `as_of` (a debit to receivables counts + /// plus for an invoice; a credit to payables for a bill): the + /// very lines the control account sums. + /// 2. paid (discounted, written off) = minus the same sum over its + /// Payment (Discount, WriteOff) lines: a reversed payment counts + /// from its reversal's date, never after. + /// 3. status = arap::doc_status(its own entry stands on `as_of`, its + /// own entry's control line carries it, ...). + /// 4. overdue_days > 0 only while something is owed past the due date. + /// 5. A pure function of (the books, as_of); NEVER panics: + /// TooLargeToCompute past exact arithmetic. + /// SCENARIOS: + /// 1. IF an invoice of 100 was paid 100 and the payment reversed: + /// SHOULD be Open, owing 100 (scenario 1 of B-38). + /// 2. IF its own entry was reversed with nothing paid: SHOULD be Void, + /// owing nothing. + /// 3. IF asked between a payment and its reversal: SHOULD be Paid. + /// ``` + pub fn doc_state( + &self, + doc: arap::DocRef, + as_of: NaiveDate, + ) -> Result, LedgerError> { + let (date, due_date, entry, total) = self.doc_header(doc)?; + if date > as_of { + return Ok(None); + } + let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures")); + let add = |a: Decimal, b: Decimal| a.checked_add(b).ok_or_else(too_large); + let (mut outstanding, mut paid, mut discounted, mut written_off) = + (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); + let lines = self.doc_lines(doc); + let origin_tagged = lines + .iter() + .any(|l| l.entry == entry && l.movement == arap::Movement::Origin); + for l in lines.into_iter().filter(|l| l.date <= as_of) { + outstanding = add(outstanding, l.amount)?; + match l.movement { + arap::Movement::Payment => paid = add(paid, -l.amount)?, + arap::Movement::Discount => discounted = add(discounted, -l.amount)?, + arap::Movement::WriteOff => written_off = add(written_off, -l.amount)?, + arap::Movement::Origin | arap::Movement::Adopted | arap::Movement::RoleMove => {} + } + } + let status = arap::doc_status( + self.stands_on(entry, as_of), + origin_tagged, + total, + outstanding, + written_off, + ); + let overdue_days = if outstanding > Decimal::ZERO && as_of > due_date { + (as_of - due_date).num_days() + } else { + 0 + }; + Ok(Some(arap::DocState { + total, + paid, + discounted, + written_off, + outstanding, + status, + overdue_days, + })) + } + + /// [`Self::doc_state`] of an invoice. + pub fn invoice_state( + &self, + id: InvoiceId, + as_of: NaiveDate, + ) -> Result, LedgerError> { + self.doc_state(arap::DocRef::Invoice(id), as_of) + } + + /// [`Self::doc_state`] of a bill. + pub fn bill_state( + &self, + id: BillId, + as_of: NaiveDate, + ) -> Result, LedgerError> { + self.doc_state(arap::DocRef::Bill(id), as_of) + } + + /// The payments a document's events recorded, each once, standing or + /// not, tagged or not: every `invoice.payment_received` or `bill.paid` + /// naming it (live or replayed). What a version before document + /// standing (plan P4b S1) counts as paid, so the store knows when a + /// payment needs that feature. Refused (TooLargeToCompute), never a + /// panic, past exact arithmetic: replayed amounts are any size (B-28). + pub fn payments_recorded(&self, doc: arap::DocRef) -> Result { + self.payment_events + .get(&doc) + .map_or(&[][..], Vec::as_slice) + .iter() + .try_fold(Decimal::ZERO, |sum, &a| sum.checked_add(a)) + .ok_or_else(|| LedgerError::TooLargeToCompute(format!("{doc}'s payments"))) + } + + /// The payment entries that stand against a document now: each + /// payment (an entry reversing nothing) that isn't reversed, once, in + /// entry order. What a void waits on, and what a bounds refusal names + /// to reverse first; never a payment's reversal, which stands too but + /// re-instating the payment is not the remedy. + pub fn standing_payments(&self, doc: arap::DocRef) -> Vec { + let mut out: Vec = self + .doc_lines(doc) + .iter() + .filter(|l| { + l.movement == arap::Movement::Payment && !l.reversal && self.in_effect(l.entry) + }) + .map(|l| l.entry) + .collect(); + out.sort_unstable(); + out.dedup(); + out + } + + /// A document's movements by date, `extra` ones added: `(date, the + /// day's movements summed)`, in date order, over the dates in `grid` + /// (each date once; a date with nothing moving is zero). + fn doc_deltas( + &self, + doc: arap::DocRef, + extra: &[(NaiveDate, Decimal)], + grid: &[NaiveDate], + ) -> Result, LedgerError> { + let mut by_date: std::collections::BTreeMap = + grid.iter().map(|&d| (d, Decimal::ZERO)).collect(); + let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures")); + let moves = self + .doc_lines(doc) + .into_iter() + .map(|l| (l.date, l.amount)) + .chain(extra.iter().copied()); + for (date, amount) in moves { + let day = by_date.entry(date).or_default(); + *day = day.checked_add(amount).ok_or_else(too_large)?; + } + Ok(by_date.into_values().collect()) + } + + /// Refuse a step that would move `doc` by `extra` (dated movements) + /// if, at some date, it would then owe less than nothing or more than + /// its total where it didn't before ([`kernel::bounds_kept`]; rules.md + /// B-38 INVARIANT 2). Books an earlier version left out of bounds + /// (a void under a payment that stands) take any step that makes no + /// date worse, so the remedy (reversing the payment) is accepted. + fn check_item_bounds( + &self, + doc: arap::DocRef, + extra: &[(NaiveDate, Decimal)], + ) -> Result<(), LedgerError> { + let (_, _, _, total) = self.doc_header(doc)?; + let mut grid: Vec = self + .doc_lines(doc) + .iter() + .map(|l| l.date) + .chain(extra.iter().map(|&(d, _)| d)) + .collect(); + grid.sort(); + grid.dedup(); + let before = self.doc_deltas(doc, &[], &grid)?; + let after = self.doc_deltas(doc, extra, &grid)?; + let (i, below) = match kernel::bounds_kept(total, &before, &after) { + kernel::Bounds::Within => return Ok(()), + kernel::Bounds::Below(i) => (i, true), + kernel::Bounds::Above(i) => (i, false), + kernel::Bounds::Overflow => { + return Err(LedgerError::TooLargeToCompute(format!("{doc}'s figures"))); + } + }; + let owed: Decimal = after[..=i].iter().sum(); + let on = grid[i]; + let payments: Vec = self + .standing_payments(doc) + .iter() + .map(|e| format!("entry {e}")) + .collect(); + let standing = if payments.is_empty() { + String::new() + } else { + format!(" (payments standing: {})", payments.join(", ")) + }; + Err(LedgerError::DocumentBounds(if below { + format!( + "{doc} would owe {owed} on {on}, less than nothing: more would be paid against it than it is for{standing}. Reverse the payment first (books.reverse_entry)" + ) + } else { + format!( + "{doc} would owe {owed} on {on}, more than its total {total}{standing}: reverse the other payment first (books.reverse_entry), then this one's reversal" + ) + })) + } + + /// What can still be paid on `doc` by a payment dated `date`: the + /// least it owes on any day from then on (from its own date, for a + /// payment dated before it), so no later day is overpaid. + fn payment_room(&self, doc: arap::DocRef, date: NaiveDate) -> Result { + let (doc_date, ..) = self.doc_header(doc)?; + let from = date.max(doc_date); + let lines = self.doc_lines(doc); + let too_large = || LedgerError::TooLargeToCompute(format!("{doc}'s figures")); + let mut running = Decimal::ZERO; + for l in lines.iter().filter(|l| l.date <= from) { + running = running.checked_add(l.amount).ok_or_else(too_large)?; + } + let mut room = running; + for l in lines.iter().filter(|l| l.date > from) { + running = running.checked_add(l.amount).ok_or_else(too_large)?; + room = room.min(running); + } + Ok(room) + } + + /// A payment's deposit or payer account: a postable asset or liability + /// that holds no role (a bank, a card, a member's payable), never a + /// control, revenue or expense account (plan P4b.3, "Payment + /// accounts"). Receivables and payables are held as the control guard + /// reads them ([`Self::control_role_of`]); the other roles by + /// [`Self::holds_role`], so a chart's own card at 2000, or a bank at a + /// payroll role's code, pays. + fn check_payment_account(&self, account: AccountId) -> Result<(), LedgerError> { + self.check_line_account(account)?; + let a = self.chart.get(account).expect("checked above"); + let refuse = |why: String| { + Err(LedgerError::PaymentAccount { + code: a.code.clone(), + name: a.name.clone(), + why, + }) + }; + if !matches!(a.account_type, AccountType::Asset | AccountType::Liability) { + return refuse(format!( + "it is {} account, not cash, a card or a payable", + match a.account_type { + AccountType::Equity => "an equity", + AccountType::Revenue => "a revenue", + _ => "an expense", + } + )); + } + if let Some(c) = self.control_role_of(account).or_else(|| { + control_accounts().find(|c| { + c.key != RECEIVABLES.key && c.key != PAYABLES.key && self.holds_role(c, account) + }) + }) { + return refuse(format!("it holds the {} role", c.role)); + } + Ok(()) + } + + /// Whether `account` fills role `c`: assigned to it, or (unassigned) + /// the account at the role's code, of the role's type and postable, + /// so the commands posting the role post there. An account of + /// another type at the code fills nothing (the commands refuse it: + /// [`Self::check_controls`]). + fn holds_role(&self, c: &ControlAccount, account: AccountId) -> bool { + match self.roles.get(c.key) { + Some(&a) => a == account, + None => self.chart.find_by_code(c.code).is_some_and(|a| { + a.id == account && a.account_type == c.account_type && !a.placeholder + }), + } } /// Record a customer payment against an invoice. Posts: Dr Cash, - /// Cr A/R. Updates the invoice's paid amount and status. - /// - /// ```text - /// GOAL: record a customer's payment against an invoice. - /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/R workflow). - /// INVARIANTS: - /// 1. MUST post Dr cash, Cr 1200 receivables, for the amount. - /// 2. The invoice's paid amount MUST rise by the amount; it is Paid - /// exactly when fully paid, Partial when partly. - /// SCENARIOS: - /// 1. IF the amount isn't positive: SHOULD refuse. - /// 2. IF it exceeds what's still owed: - /// - SHOULD refuse (Overpayment) - /// - NEVER let paid exceed the total - /// ``` + /// Cr A/R ([`Self::receive_payment_with`], with no payment method or + /// reference). pub fn receive_payment( &mut self, invoice_id: InvoiceId, @@ -5142,77 +5851,62 @@ impl Ledger { amount: Decimal, cash_account: AccountId, ) -> Result { - self.atomically(|l| l.receive_payment_steps(invoice_id, date, amount, cash_account)) + self.receive_payment_with( + invoice_id, + date, + amount, + cash_account, + &arap::PaymentDetails::default(), + ) } - /// The steps of [`Self::receive_payment`], run inside [`Self::atomically`]. - fn receive_payment_steps( + /// Record a customer payment against an invoice, with its payment + /// method and reference. Posts: Dr the deposit account, Cr A/R. + /// + /// ```text + /// GOAL: record a customer's payment against an invoice. + /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/R workflow); rules.md + /// B-38; plan/served.md P4b.3 ("Payment accounts"), P4b.6. + /// INVARIANTS: + /// 1. MUST post Dr the deposit account, Cr 1200 receivables, for the + /// amount; the receivables line carries the invoice (Payment), so + /// what it owes falls by the amount from the payment's date. + /// 2. The payment method, when given, tags the entry in the same + /// step (D-40); the reference is in its description. + /// 3. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the amount isn't positive: SHOULD refuse. + /// 2. IF it exceeds what's still owed on any day from its date on: + /// - SHOULD refuse (Overpayment), saying what can be paid + /// - NEVER let a standing payment exceed the total + /// - a reversed payment no longer counts, so the right one is + /// accepted after the wrong one is reversed (B-38 scenario 5) + /// 3. IF the deposit account is a control, revenue, expense or + /// equity account, or holds any role: SHOULD refuse + /// (PaymentAccount; B-38 scenario 8). + /// 4. IF dated before the invoice: SHOULD refuse (PaymentBeforeDocument). + /// ``` + pub fn receive_payment_with( &mut self, invoice_id: InvoiceId, date: NaiveDate, amount: Decimal, cash_account: AccountId, + details: &arap::PaymentDetails, ) -> Result { - if amount <= Decimal::ZERO { - return Err(LedgerError::InvalidPaymentAmount(amount)); - } - let invoice = self - .invoices - .get(&invoice_id) - .ok_or(LedgerError::UnknownInvoice(invoice_id))? - .clone(); - let total = invoice.total()?; - let balance = invoice.balance()?; - if amount > balance { - return Err(LedgerError::Overpayment(amount, balance)); - } - if let Some(pid) = self.closed_period_containing(date) { - return Err(LedgerError::ClosedPeriod(pid, date.to_string())); - } - if date < invoice.date { - return Err(LedgerError::PaymentBeforeDocument { + self.atomically(|l| { + l.payment_steps( + arap::DocRef::Invoice(invoice_id), date, - document_date: invoice.date, - }); - } - self.check_controls(&[RECEIVABLES])?; - // Every check before the entry exists: a refusal leaves no draft. - self.check_line_account(cash_account)?; - let ar = self.ensure_control(&RECEIVABLES); - let entry = self.create_entry(date, &format!("Payment on invoice {}", invoice_id)); - let money = Money::new(amount, self.base_currency); - self.add_line(entry, cash_account, Side::Debit, money)?; - self.add_line(entry, ar, Side::Credit, money)?; - self.post_entry(entry)?; - let inv = self.invoices.get_mut(&invoice_id).expect("invoice exists"); - // At most the balance: the sum is at most the total. - inv.amount_paid += amount; - inv.status = arap::derive_status(inv.amount_paid, total); - let customer = inv.customer_id; - self.invoice_payments - .entry(invoice_id) - .or_default() - .push((entry, amount)); - self.tag_customer(entry, customer); - Ok(entry) + amount, + cash_account, + details, + ) + }) } - /// Pay a vendor bill. Posts: Dr A/P, Cr Cash. Updates the bill's paid - /// amount and status. - /// - /// ```text - /// GOAL: record paying a vendor's bill. - /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/P workflow). - /// INVARIANTS: - /// 1. MUST post Dr 2000 payables, Cr cash, for the amount, tagged with - /// the vendor. - /// 2. The bill's paid amount MUST rise by the amount. - /// SCENARIOS: - /// 1. IF the amount isn't positive, or exceeds what's still owed: SHOULD - /// refuse. - /// 2. IF dated before the bill: SHOULD refuse (PaymentBeforeDocument); - /// the bill's own date is fine (DECISION, owner 2026-10-13). - /// ``` + /// Pay a vendor bill. Posts: Dr A/P, Cr Cash ([`Self::pay_bill_with`], + /// with no payment method or reference). pub fn pay_bill( &mut self, bill_id: BillId, @@ -5220,64 +5914,458 @@ impl Ledger { amount: Decimal, cash_account: AccountId, ) -> Result { - self.atomically(|l| l.pay_bill_steps(bill_id, date, amount, cash_account)) + self.pay_bill_with( + bill_id, + date, + amount, + cash_account, + &arap::PaymentDetails::default(), + ) } - /// The steps of [`Self::pay_bill`], run inside [`Self::atomically`]. - fn pay_bill_steps( + /// Pay a vendor bill, with its payment method and reference. Posts: + /// Dr A/P, Cr the payer account. + /// + /// ```text + /// GOAL: record paying a vendor's bill. + /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/P workflow); rules.md + /// B-38; plan/served.md P4b.3, P4b.6. + /// INVARIANTS: + /// 1. MUST post Dr 2000 payables, Cr the payer account, for the + /// amount, tagged with the vendor; the payables line carries the + /// bill (Payment). + /// 2. The payment method, when given, tags the entry in the same + /// step (a card-paid bill is left out of 1099-NEC, D-40). + /// 3. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the amount isn't positive, or exceeds what's still owed on + /// any day from its date on: SHOULD refuse. + /// 2. IF dated before the bill: SHOULD refuse (PaymentBeforeDocument); + /// the bill's own date is fine (DECISION, owner 2026-10-13). + /// 3. IF the payer account isn't a bank, card or payable that holds + /// no role: SHOULD refuse (PaymentAccount). + /// ``` + pub fn pay_bill_with( &mut self, bill_id: BillId, date: NaiveDate, amount: Decimal, cash_account: AccountId, + details: &arap::PaymentDetails, + ) -> Result { + self.atomically(|l| { + l.payment_steps( + arap::DocRef::Bill(bill_id), + date, + amount, + cash_account, + details, + ) + }) + } + + /// 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 { if amount <= Decimal::ZERO { return Err(LedgerError::InvalidPaymentAmount(amount)); } - let bill = self - .bills - .get(&bill_id) - .ok_or(LedgerError::UnknownBill(bill_id))? - .clone(); - let total = bill.total()?; - let balance = bill.balance()?; - if amount > balance { - return Err(LedgerError::Overpayment(amount, balance)); + 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 < bill.date { + if date < doc_date { return Err(LedgerError::PaymentBeforeDocument { date, - document_date: bill.date, + document_date: doc_date, }); } - self.check_controls(&[PAYABLES])?; + 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_line_account(cash_account)?; - let ap = self.ensure_control(&PAYABLES); - let entry = self.create_entry(date, &format!("Payment on bill {}", bill_id)); + self.check_payment_account(account)?; + let control = self.ensure_control(&role); let money = Money::new(amount, self.base_currency); - self.add_line(entry, ap, Side::Debit, money)?; - self.add_line(entry, cash_account, Side::Credit, money)?; + 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)?; - let b = self.bills.get_mut(&bill_id).expect("bill exists"); - b.amount_paid += amount; - b.status = arap::derive_status(b.amount_paid, total); - self.bill_payments - .entry(bill_id) - .or_default() - .push((entry, amount)); - self.tag_vendor(entry, bill.vendor_id); + 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 + /// [`Self::reverse_entry`] of the same entry gives the same result. + /// + /// ```text + /// GOAL: a document that should never have existed (a duplicate, the + /// wrong customer) is undone by an opposite entry, never erased. + /// SOURCE: docs/spec/rules.md B-9, B-10, B-12, B-38 (scenarios 2, 3); + /// plan/served.md P4b.5, P4b.6; ASC 250 (an immaterial error is + /// corrected in the period found). + /// INVARIANTS: + /// 1. MUST post exactly the reversal reverse_entry would, of the + /// entry whose reversal voids it (its own entry, or the last + /// reversal in its chain when it was voided and re-instated). + /// 2. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the reason is blank, more than one line, or longer than 200 + /// characters: SHOULD refuse (InvalidVoid). + /// 2. IF it is void already: SHOULD refuse (InvalidVoid), naming the + /// reversal (reverse that to re-instate it). + /// 3. IF a payment stands against it: SHOULD refuse (DocumentBounds), + /// naming the payment to reverse first. + /// 4. IF dated before the document, or in a closed period: SHOULD + /// refuse (check_reversal). + /// ``` + pub fn void_document( + &mut self, + doc: arap::DocRef, + date: NaiveDate, + reason: &str, + ) -> Result { + let (target, description) = self.check_void_document(doc, date, reason)?; + self.reverse_entry(target, date, &description) + } + + /// Every refusal of [`Self::void_document`], without touching the + /// books: the entry to reverse and the reversal's description. + pub fn check_void_document( + &self, + doc: arap::DocRef, + date: NaiveDate, + reason: &str, + ) -> Result<(EntryId, String), LedgerError> { + let reason = reason.trim(); + if reason.is_empty() + || reason.contains(['\n', '\r']) + || reason.chars().count() > arap::MAX_REFERENCE_CHARS + { + return Err(LedgerError::InvalidVoid(format!( + "a void needs its reason: one line of at most {} characters", + arap::MAX_REFERENCE_CHARS + ))); + } + let (_, _, origin, _) = self.doc_header(doc)?; + let chain = self.reversal_chain(origin); + let target = *chain.last().unwrap_or(&origin); + if !self.in_effect(origin) { + return Err(LedgerError::InvalidVoid(format!( + "{doc} is void already: entry {target} reversed it (reverse that entry to re-instate it)" + ))); + } + self.check_reversal(target, date)?; + Ok((target, format!("Void {doc}: {reason}"))) + } + + /// The control accounts' tie-out as of a day (plan P4b.10, + /// `books.control_check`): for each control account + /// ([`Self::control_role_of`]: assigned receivables or payables, or + /// holding a document's lines), its balance, what its documents owe + /// on it, its lines that carry no document, and (on the role's first + /// account) the role's documents needing attention. + /// + /// ```text + /// GOAL: show that receivables and payables equal their documents, and + /// list every line that is no document's. + /// SOURCE: docs/spec/rules.md B-23, B-38 INVARIANT 1: + /// GL(A, D) = Σ_docs outstanding(d, D) + unattached(A, D). + /// INVARIANTS: + /// 1. balance sums every posted line on the account dated on or + /// before `as_of`; documents_total sums the tagged ones through + /// their documents (Self::doc_lines); unattached lists the rest, + /// each reversal chain once, at what its lines net to as of the + /// day (a reversed hand line nets to nothing and is not listed). + /// 2. difference = balance - documents_total - Σ unattached is zero + /// in every books (an identity: both sides sum the same lines). + /// 3. attention lists each document of the role issued by `as_of` + /// whose state then is out of bounds (a credit balance, which + /// aging leaves out, or more owed than its total) or Unattached: + /// what books an earlier version wrote can hold (plan P4b.14 + /// item 3), never what these commands write. + /// 4. NEVER panics: TooLargeToCompute past exact arithmetic. + /// ``` + pub fn control_check(&self, as_of: NaiveDate) -> Result, LedgerError> { + let too_large = || LedgerError::TooLargeToCompute("a control account's balance".into()); + // Each control account (Self::control_role_of), and the role + // whose documents it holds: a debit counts plus on receivables, a + // credit on payables. + let mut accounts: Vec<(AccountId, &'static ControlAccount)> = [&RECEIVABLES, &PAYABLES] + .into_iter() + .flat_map(|c| { + self.document_role_accounts(c) + .into_iter() + .map(move |a| (a, c)) + }) + .collect(); + let mut doc_totals: HashMap = HashMap::new(); + let mut attention: HashMap<&'static str, Vec> = HashMap::new(); + for doc in self.documents() { + let role = match doc { + arap::DocRef::Invoice(_) => &RECEIVABLES, + arap::DocRef::Bill(_) => &PAYABLES, + }; + for l in self.doc_lines(doc).into_iter().filter(|l| l.date <= as_of) { + let t = doc_totals.entry(l.account).or_default(); + *t = t.checked_add(l.amount).ok_or_else(too_large)?; + } + if let Some(st) = self.doc_state(doc, as_of)? + && (st.out_of_bounds() || st.status == arap::DocStatus::Unattached) + { + attention + .entry(role.key) + .or_default() + .push(arap::DocAttention { + doc, + status: st.status, + total: st.total, + outstanding: st.outstanding, + }); + } + } + // A role whose documents carry no line anywhere (each unattached) + // still gets its row, on the account the role posts to now. + for c in [&RECEIVABLES, &PAYABLES] { + if attention.contains_key(c.key) + && !accounts.iter().any(|&(_, r)| r.key == c.key) + && let Some(a) = self.role_account(c) + { + accounts.push((a, c)); + } + } + let counted = |role: &ControlAccount, side: Side, amount: Decimal| { + let plus = if role.key == RECEIVABLES.key { + Side::Debit + } else { + Side::Credit + }; + if side == plus { amount } else { -amount } + }; + let mut out = Vec::new(); + for (account, role) in accounts { + let mut balance = Decimal::ZERO; + let mut tops: Vec<(EntryId, usize)> = Vec::new(); + for e in self.posted_entries().filter(|e| e.date() <= as_of) { + for (i, l) in e.lines().iter().enumerate() { + if l.account != account { + continue; + } + balance = balance + .checked_add(counted(role, l.side, l.amount.amount)) + .ok_or_else(too_large)?; + if !self.line_docs.contains_key(&(e.id(), i)) { + let top = self.reversal_chain(e.id())[0]; + if !tops.contains(&(top, i)) { + tops.push((top, i)); + } + } + } + } + let mut unattached = Vec::new(); + let mut unattached_sum = Decimal::ZERO; + for (top, i) in tops { + // The chain's lines dated by `as_of`, each the mirror of + // the one before: they net to k times the top's line. + let mut net = Decimal::ZERO; + for (n, e) in self.reversal_chain(top).into_iter().enumerate() { + let Some(e) = self.entries.get(&e).filter(|e| e.date() <= as_of) else { + continue; + }; + let Some(l) = e.lines().get(i) else { + continue; + }; + debug_assert!(n == 0 || e.reversal_of().is_some()); + net = net + .checked_add(counted(role, l.side, l.amount.amount)) + .ok_or_else(too_large)?; + } + if !net.is_zero() { + unattached_sum = unattached_sum.checked_add(net).ok_or_else(too_large)?; + unattached.push(arap::UnattachedLine { + entry: top, + line: i, + date: self.entries[&top].date(), + amount: net, + }); + } + } + let documents_total = doc_totals.get(&account).copied().unwrap_or_default(); + let difference = balance + .checked_sub(documents_total) + .and_then(|d| d.checked_sub(unattached_sum)) + .ok_or_else(too_large)?; + out.push(arap::ControlCheck { + role: role.key.to_string(), + account, + balance, + documents_total, + unattached, + difference, + attention: attention.remove(role.key).unwrap_or_default(), + }); + } + Ok(out) + } + + /// Refresh a document's record figures ([`Invoice::amount_paid`], + /// [`Invoice::owed`], [`Invoice::status`]) from its tagged lines as of + /// the end of the books: what was settled (paid, discounted, written + /// off, as they stand), what is owed, and the three-word status of + /// what was settled. A void settles nothing, so it never reads as + /// paid. Never refuses: a document whose figures can't be computed + /// keeps the last ones (its state refuses them, `doc_state`). + fn refresh_doc(&mut self, doc: arap::DocRef) { + let Ok(Some(st)) = self.doc_state(doc, NaiveDate::MAX) else { + return; + }; + let Some(settled) = st + .paid + .checked_add(st.discounted) + .and_then(|s| s.checked_add(st.written_off)) + else { + return; + }; + // Nothing settled is zero as the record always started it (a + // sum's scale would show as "0.00"); what is owed keeps the + // lines' scale, as the total less the settled did ("0.00"). + let settled = if settled.is_zero() { + Decimal::ZERO + } else { + settled + }; + let owed = st.outstanding; + let status = arap::derive_status(settled, st.total); + match doc { + arap::DocRef::Invoice(id) => { + if let Some(i) = self.invoices.get_mut(&id) { + (i.amount_paid, i.owed, i.status) = (settled, owed, status); + } + } + arap::DocRef::Bill(id) => { + if let Some(b) = self.bills.get_mut(&id) { + (b.amount_paid, b.owed, b.status) = (settled, owed, status); + } + } + } + } + + /// [`Self::refresh_doc`] for every document `entry`'s lines carry. + fn refresh_docs_of(&mut self, entry: EntryId) { + let docs: Vec = self + .line_docs + .range((entry, 0)..(entry + 1, 0)) + .map(|(_, t)| t.doc) + .collect(); + for d in docs { + self.refresh_doc(d); + } + } + + /// On replay, tag a document's control line in `entry` (plan P4b.3, + /// "How tags get set"): the first line, in line order, on the role's + /// account as the role stands at this point of the journal, on the + /// side the movement posts (the owed side for the document's own + /// line, the other for a payment), that carries no document yet and + /// is for `amount` (any amount when `None`: a document's own entry + /// has one such line). An entry with + /// no such line stays untagged, and its line is listed by + /// [`Self::control_check`]: replay never refuses (B-28). + fn tag_replayed( + &mut self, + entry: EntryId, + role: &ControlAccount, + amount: Option, + tag: arap::DocTag, + ) { + let (Some(account), Some(e)) = (self.role_account(role), self.entries.get(&entry)) else { + return; + }; + // The side the movement posts: a document's own line raises what + // it owes, a payment lowers it. An older payment deposited into + // the control account itself (Dr 1200 / Cr 1200, plan P4b.2 gap + // 5) has a line on each side; only the right one is the payment. + let side = match tag.movement { + arap::Movement::Origin => tag.doc.owed_side(), + _ => kernel::flip(tag.doc.owed_side()), + }; + let at = e.lines().iter().enumerate().position(|(i, l)| { + l.account == account + && l.side == side + && !self.line_docs.contains_key(&(entry, i)) + && amount.is_none_or(|a| a == l.amount.amount) + }); + if let Some(i) = at { + // A document's own entry has one control line: two would be no + // entry an earlier version wrote, so neither is tagged. + let lines_on = e.lines().iter().filter(|l| l.account == account).count(); + if amount.is_some() || lines_on == 1 { + self.tag_line(entry, i, 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 - /// only restores the invoice record. The id must be the next invoice - /// id — anything else means the journal and the allocator disagree. + /// restores the invoice record and tags its receivable line (the one + /// line on the receivables role's account as it stands at this point + /// of the journal). The id must be the next invoice id — anything else + /// means the journal and the allocator disagree. #[doc(hidden)] pub fn replay_invoice( &mut self, @@ -5304,11 +6392,23 @@ impl Ledger { due_date, lines, amount_paid: Decimal::ZERO, + owed: Decimal::ZERO, status: DocumentStatus::Open, entry_id, }, ); self.tag_customer(entry_id, customer_id); + let doc = arap::DocRef::Invoice(id); + self.tag_replayed( + entry_id, + &RECEIVABLES, + None, + arap::DocTag { + doc, + movement: arap::Movement::Origin, + }, + ); + self.refresh_doc(doc); Ok(()) } @@ -5340,19 +6440,32 @@ impl Ledger { due_date, lines, amount_paid: Decimal::ZERO, + owed: Decimal::ZERO, status: DocumentStatus::Open, entry_id, }, ); self.tag_vendor(entry_id, vendor_id); + let doc = arap::DocRef::Bill(id); + self.tag_replayed( + entry_id, + &PAYABLES, + None, + arap::DocTag { + doc, + movement: arap::Movement::Origin, + }, + ); + self.refresh_doc(doc); Ok(()) } /// Apply an invoice payment without posting: replay support for /// journaling stores. The payment entry was already rebuilt from its - /// own entry events; this only moves the invoice's paid amount and - /// status. `entry_id` is the journaled payment entry, carried for - /// audit; the invoice does not link payment entries. + /// own entry events; this tags its receivables line with the invoice + /// (the first untagged one for `amount`, so several payment events + /// naming one entry tag its lines in order: the ordinal rule, plan + /// P4b.3). Nothing is bounded on replay (rules.md B-28). #[doc(hidden)] pub fn replay_invoice_payment( &mut self, @@ -5360,24 +6473,24 @@ impl Ledger { amount: Decimal, entry_id: EntryId, ) -> Result<(), LedgerError> { - let inv = self + let customer = self .invoices - .get_mut(&invoice_id) - .ok_or(LedgerError::UnknownInvoice(invoice_id))?; - // No bound on a journaled payment (rules.md B-28); a sum past exact - // arithmetic is refused, never wrapped, before anything changes. - let total = inv.total()?; - inv.amount_paid = inv - .amount_paid - .checked_add(amount) - .ok_or_else(|| LedgerError::TooLargeToCompute("the invoice's paid amount".into()))?; - self.invoice_payments - .entry(invoice_id) - .or_default() - .push((entry_id, amount)); - inv.status = arap::derive_status(inv.amount_paid, total); - let customer = inv.customer_id; + .get(&invoice_id) + .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, + }, + ); self.tag_customer(entry_id, customer); + self.payment_events.entry(doc).or_default().push(amount); + self.refresh_doc(doc); Ok(()) } @@ -5390,24 +6503,24 @@ impl Ledger { amount: Decimal, entry_id: EntryId, ) -> Result<(), LedgerError> { - let bill = self + let vendor = self .bills - .get_mut(&bill_id) - .ok_or(LedgerError::UnknownBill(bill_id))?; - // No bound on a journaled payment (rules.md B-28); a sum past exact - // arithmetic is refused, never wrapped, before anything changes. - let total = bill.total()?; - bill.amount_paid = bill - .amount_paid - .checked_add(amount) - .ok_or_else(|| LedgerError::TooLargeToCompute("the bill's paid amount".into()))?; - bill.status = arap::derive_status(bill.amount_paid, total); - self.bill_payments - .entry(bill_id) - .or_default() - .push((entry_id, amount)); - let vendor = bill.vendor_id; + .get(&bill_id) + .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, + }, + ); self.tag_vendor(entry_id, vendor); + self.payment_events.entry(doc).or_default().push(amount); + self.refresh_doc(doc); Ok(()) } @@ -5415,91 +6528,73 @@ impl Ledger { /// balance, bucketed by days past due. Refused (TooLargeToCompute), /// never a panic, when a sum is past exact arithmetic: documents are /// restored as journaled (rules.md B-28). + /// + /// ```text + /// GOAL: what each customer owes as of a day, by how overdue. + /// SOURCE: docs/spec/rules.md B-18, B-38 INVARIANT 5. + /// INVARIANTS: + /// 1. Each invoice issued by `as_of` that owes something then + /// (Self::doc_state's outstanding) is in exactly one bucket, at + /// that amount: the buckets partition what the invoices owe. + /// 2. A void invoice, a paid one, and one dated after `as_of` are + /// left out; a payment reversed after `as_of` still counts as + /// paid, one reversed by then doesn't. + /// 3. One owing less than nothing (a credit balance, in books an + /// earlier version left out of bounds) is left out too: it is + /// owed back, not receivable (ASC 310-10), and + /// Self::control_check lists it. So the buckets sum to what the + /// invoices owing something owe: the documents' total less those + /// credit balances. + /// ``` pub fn ar_aging(&self, as_of: NaiveDate) -> Result, LedgerError> { - let mut by_customer: HashMap = HashMap::new(); - for inv in self.invoices.values() { - // Only what existed by `as_of`: issued by then, less payments - // made by then (owner, 2026-10-13). - if inv.date > as_of { + self.aging(as_of, true) + } + + /// A/P aging as of a date: one row per vendor with an outstanding + /// balance, bucketed by days past due; as [`Self::ar_aging`] is. + pub fn ap_aging(&self, as_of: NaiveDate) -> Result, LedgerError> { + self.aging(as_of, false) + } + + /// [`Self::ar_aging`] (`invoices`) or [`Self::ap_aging`]. + fn aging(&self, as_of: NaiveDate, invoices: bool) -> Result, LedgerError> { + let mut by_party: HashMap = HashMap::new(); + for doc in self.documents() { + let (party, name) = match doc { + arap::DocRef::Invoice(id) if invoices => { + let c = self.invoices[&id].customer_id; + (c, self.customers.get(&c).map(|c| c.name.clone())) + } + arap::DocRef::Bill(id) if !invoices => { + let v = self.bills[&id].vendor_id; + (v, self.vendors.get(&v).map(|v| v.name.clone())) + } + _ => continue, + }; + let Some(st) = self.doc_state(doc, as_of)? else { + continue; + }; + // Nothing owed, or a credit balance (Overpaid, or a void under + // a payment that stands): no receivable or payable to age; the + // control check lists the credit balances. + if st.outstanding <= Decimal::ZERO { continue; } - let paid = self.paid_by(self.invoice_payments.get(&inv.id), as_of)?; - let balance = inv.total()?.checked_sub(paid).ok_or_else(|| { - LedgerError::TooLargeToCompute(format!("invoice {}'s balance", inv.id)) - })?; - if balance.is_zero() { - continue; - } - let row = arap::aging_bucket(inv.due_date, as_of, balance); - let entry = by_customer - .entry(inv.customer_id) + let (_, due_date, ..) = self.doc_header(doc)?; + let row = arap::aging_bucket(due_date, as_of, st.outstanding); + by_party + .entry(party) .or_insert_with(|| AgingRow { - name: self - .customers - .get(&inv.customer_id) - .map(|c| c.name.clone()) - .unwrap_or_default(), + name: name.unwrap_or_default(), current: Decimal::ZERO, days_30: Decimal::ZERO, days_60: Decimal::ZERO, days_90: Decimal::ZERO, days_90_plus: Decimal::ZERO, - }); - entry.add(&row)?; + }) + .add(&row)?; } - let mut rows: Vec = by_customer.into_values().collect(); - rows.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(rows) - } - - /// What a document's payments add up to through `as_of`: payments are - /// posted entries, dated. - fn paid_by( - &self, - payments: Option<&Vec<(EntryId, Decimal)>>, - as_of: NaiveDate, - ) -> Result { - payments - .into_iter() - .flatten() - .filter(|(e, _)| self.get_entry(*e).is_some_and(|e| e.date() <= as_of)) - .try_fold(Decimal::ZERO, |sum, (_, amount)| sum.checked_add(*amount)) - .ok_or_else(|| LedgerError::TooLargeToCompute("a document's payments".into())) - } - - /// A/P aging as of a date: one row per vendor with an outstanding - /// balance, bucketed by days past due; refused as - /// [`Self::ar_aging`] is. - pub fn ap_aging(&self, as_of: NaiveDate) -> Result, LedgerError> { - let mut by_vendor: HashMap = HashMap::new(); - for bill in self.bills.values() { - // Only what existed by `as_of` (see `ar_aging`). - if bill.date > as_of { - continue; - } - let paid = self.paid_by(self.bill_payments.get(&bill.id), as_of)?; - let balance = bill.total()?.checked_sub(paid).ok_or_else(|| { - LedgerError::TooLargeToCompute(format!("bill {}'s balance", bill.id)) - })?; - if balance.is_zero() { - continue; - } - let row = arap::aging_bucket(bill.due_date, as_of, balance); - let entry = by_vendor.entry(bill.vendor_id).or_insert_with(|| AgingRow { - name: self - .vendors - .get(&bill.vendor_id) - .map(|v| v.name.clone()) - .unwrap_or_default(), - current: Decimal::ZERO, - days_30: Decimal::ZERO, - days_60: Decimal::ZERO, - days_90: Decimal::ZERO, - days_90_plus: Decimal::ZERO, - }); - entry.add(&row)?; - } - let mut rows: Vec = by_vendor.into_values().collect(); + let mut rows: Vec = by_party.into_values().collect(); rows.sort_by(|a, b| a.name.cmp(&b.name)); Ok(rows) } diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 20b29fd..4058909 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -48,8 +48,9 @@ pub mod tax_capital; pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side}; pub use arap::{ - AgingRow, Bill, BillId, Customer, CustomerId, DocumentLine, DocumentStatus, Invoice, InvoiceId, - Vendor, VendorId, + AgingRow, Bill, BillId, ControlCheck, Customer, CustomerId, DocLine, DocRef, DocState, + DocStatus, DocTag, DocumentLine, DocumentStatus, Invoice, InvoiceId, Movement, PaymentDetails, + UnattachedLine, Vendor, VendorId, }; pub use assets::{ Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod, diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index c79d899..514c8d9 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -512,6 +512,14 @@ pub fn replaying(f: impl FnOnce() -> T) -> T { f() } +/// Whether a journaled line is being replayed now ([`replaying`]): the +/// rules a later version added that refuse new work (the control guard, +/// a document's bounds, rules.md B-38) never refuse a line an earlier +/// version accepted (B-28). +pub fn is_replaying() -> bool { + REPLAYING.with(std::cell::Cell::get) +} + /// The separators P2 (and every version before P3) read between a TIN's /// digits: space, hyphen, dot, slash, underscore, the no-break space and /// the dashes U+2010 to U+2014 and U+2212. diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index b0233c1..fa0cd53 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -1807,6 +1807,91 @@ fn unrecovered_cost_laws() { kani::cover!(u == 0 && revenue > expenses, "repaid, and more"); } +/// `kernel::running_bounds` (rules.md B-38 INVARIANT 2; the bounds every +/// document command and reversal checks), for every total and every +/// sequence of up to `N` dated movements a line could carry: it says +/// Within exactly when every running balance is in `0 ..= total`, and +/// otherwise names the first one that isn't, and which way it left. +#[kani::proof] +#[kani::unwind(6)] +fn bounds_check_matches_definition() { + let total = any_amount(); + kani::assume(total > 0); + let deltas: [i64; N] = core::array::from_fn(|_| any_amount()); + let n = any_len(); + let got = kernel::running_bounds(total, &deltas[..n]); + // The definition, in wide arithmetic: amounts are at most 2^40 and at + // most N are summed, so no prefix overflows (got is never Overflow). + let mut running = 0i64; + let mut first: Option<(usize, bool)> = None; + for (i, &d) in deltas[..n].iter().enumerate() { + running += d; + if first.is_none() && (running < 0 || running > total) { + first = Some((i, running < 0)); + } + } + match first { + None => assert_eq!(got, kernel::Bounds::Within), + Some((i, true)) => assert_eq!(got, kernel::Bounds::Below(i)), + Some((i, false)) => assert_eq!(got, kernel::Bounds::Above(i)), + } + kani::cover!( + got == kernel::Bounds::Within && n == N, + "a whole history in bounds" + ); + kani::cover!(matches!(got, kernel::Bounds::Below(_)), "overpaid"); + kani::cover!( + matches!(got, kernel::Bounds::Above(_)), + "owes more than its total" + ); +} + +/// `kernel::bounds_kept` (the check every document command and reversal +/// runs, rules.md B-38 INVARIANT 2): on books in bounds at every date it +/// is the definition (`running_bounds` of the books after the step); and +/// whenever it says Within, no running balance after the step is further +/// outside `0 ..= total` than before it, so a step that mends books an +/// earlier version left out of bounds is taken and none makes them worse. +#[kani::proof] +#[kani::unwind(6)] +fn bounds_kept_is_the_definition_on_books_in_bounds() { + let total = any_amount(); + kani::assume(total > 0); + let before: [i64; N] = core::array::from_fn(|_| any_amount()); + let after: [i64; N] = core::array::from_fn(|_| any_amount()); + let n = any_len(); + let got = kernel::bounds_kept(total, &before[..n], &after[..n]); + let out = |r: i64| { + if r < 0 { + -r + } else if r > total { + r - total + } else { + 0 + } + }; + let was_within = kernel::running_bounds(total, &before[..n]) == kernel::Bounds::Within; + if was_within { + assert_eq!(got, kernel::running_bounds(total, &after[..n])); + } + if got == kernel::Bounds::Within { + let (mut was, mut now) = (0i64, 0i64); + for i in 0..n { + was += before[i]; + now += after[i]; + assert!(out(now) <= out(was)); + } + } + kani::cover!( + got == kernel::Bounds::Within && !was_within, + "a step that mends out-of-bounds books" + ); + kani::cover!( + matches!(got, kernel::Bounds::Below(_)), + "a step that overpays" + ); +} + // --------------------------------------------------------------------------- // The checker itself // --------------------------------------------------------------------------- diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index ca9087d..999b959 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -383,6 +383,12 @@ struct Event { /// The payment method (an `setup.instruments` label) it was paid with. #[serde(default)] instrument: Option, + /// An entry as an earlier version could write it and this version's + /// commands refuse: a line by hand on a receivables or payables + /// account (the control guard, rules.md B-38). Posted as replay posts + /// it, since books that hold it must still read right. + #[serde(default)] + older: bool, } /// A depreciation run of `asset` for `period` ("YYYY-MM"): what is due @@ -1315,6 +1321,10 @@ impl<'a> Books<'a> { "describes its own entries: no description" } ); + assert!( + !e.older || kind == "lines", + "{ex} event {n}: older is an entry's lines, as an earlier version wrote them" + ); assert!( e.payee.is_none() || kind == "lines", "{ex} event {n}: a payee is an entry's: a reversal takes its original's, a bill names its vendor, an invoice its customer, a close and a command have none" @@ -1471,25 +1481,32 @@ impl<'a> Books<'a> { let day = || when.expect("checked: dated"); match kind { "lines" => { - let entry = self.store.create_entry(day(), description)?; - for (i, line) in e.lines.iter().enumerate() { - let side = match line.side.as_str() { - "debit" => Side::Debit, - "credit" => Side::Credit, - other => panic!("{what}: side {other:?}"), - }; - let amount = Money::new(money(&line.amount), self.currency); - let account = self.id(&line.account); - self.store.add_line(entry, account, side, amount)?; - // Tagged as books.add_line tags it: after the line. - if let Some(p) = &line.project { - let class = *self.projects.get(p).unwrap_or_else(|| { - panic!("{what}: project {p:?} not in setup.projects") - }); - self.store.set_line_class(entry, i, Some(class))?; + let post = |this: &mut Self| -> Result<(), StoreError> { + let entry = this.store.create_entry(day(), description)?; + for (i, line) in e.lines.iter().enumerate() { + let side = match line.side.as_str() { + "debit" => Side::Debit, + "credit" => Side::Credit, + other => panic!("{what}: side {other:?}"), + }; + let amount = Money::new(money(&line.amount), this.currency); + let account = this.id(&line.account); + this.store.add_line(entry, account, side, amount)?; + // Tagged as books.add_line tags it: after the line. + if let Some(p) = &line.project { + let class = *this.projects.get(p).unwrap_or_else(|| { + panic!("{what}: project {p:?} not in setup.projects") + }); + this.store.set_line_class(entry, i, Some(class))?; + } } + this.store.post_entry(entry) + }; + if e.older { + ledger_core::parties::replaying(|| post(self)) + } else { + post(self) } - self.store.post_entry(entry) } "reverses" => { let of = e.reverses.expect("checked: a reversal"); diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs index d381978..e4bbbf8 100644 --- a/crates/ledger-core/tests/form1099.rs +++ b/crates/ledger-core/tests/form1099.rs @@ -114,11 +114,17 @@ fn pay( cr: AccountId, amt: &str, ) -> EntryId { - let e = l.create_entry(date, "payment"); - let m = Money::new(dec(amt), Currency::USD); - l.add_line(e, dr, Side::Debit, m).unwrap(); - l.add_line(e, cr, Side::Credit, m).unwrap(); - l.post_entry(e).unwrap(); + // As replay posts it: money from a vendor on receivables (1200) by + // hand, as books an earlier version wrote can hold it; the control + // guard (rules.md B-38) refuses that as new work. + let e = ledger_core::parties::replaying(|| { + let e = l.create_entry(date, "payment"); + let m = Money::new(dec(amt), Currency::USD); + l.add_line(e, dr, Side::Debit, m).unwrap(); + l.add_line(e, cr, Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + e + }); if let Some(v) = vendor { l.set_entry_vendor(e, Some(v)).unwrap(); } @@ -2433,11 +2439,15 @@ proptest! { if dr == cr { continue; } - let e = l.create_entry(d(2026, month, 10), "generated"); - let m = Money::new(Decimal::new(i64::from(cents), 2), Currency::USD); - l.add_line(e, accounts[dr], Side::Debit, m).unwrap(); - l.add_line(e, accounts[cr], Side::Credit, m).unwrap(); - l.post_entry(e).unwrap(); + // As replay posts it (a hand line on receivables: see `pay`). + let e = ledger_core::parties::replaying(|| { + let e = l.create_entry(d(2026, month, 10), "generated"); + let m = Money::new(Decimal::new(i64::from(cents), 2), Currency::USD); + l.add_line(e, accounts[dr], Side::Debit, m).unwrap(); + l.add_line(e, accounts[cr], Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + e + }); l.set_entry_vendor(e, Some(vendors[v])).unwrap(); if let Some(i) = methods[method % methods.len()] { l.set_entry_instrument(e, Some(i)).unwrap(); diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index fe6ea9f..5ecb581 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -32,11 +32,17 @@ fn usd(amount: u64) -> Money { /// Post a balanced entry in one call. Panics on programmer error: fixtures /// must be valid by construction. fn post(ledger: &mut Ledger, date: NaiveDate, description: &str, lines: &[(AccountId, Side, u64)]) { - let id = ledger.create_entry(date, description); - for (account, side, amount) in lines { - ledger.add_line(id, *account, *side, usd(*amount)).unwrap(); - } - ledger.post_entry(id).unwrap(); + // As replay posts it: these books hold hand lines on receivables or + // payables (1200, 2000), which books an earlier version wrote still + // hold and every report must read; the control guard (rules.md B-38) + // refuses them as new work. + ledger_core::parties::replaying(|| { + let id = ledger.create_entry(date, description); + for (account, side, amount) in lines { + ledger.add_line(id, *account, *side, usd(*amount)).unwrap(); + } + ledger.post_entry(id).unwrap(); + }) } struct Company { @@ -649,21 +655,26 @@ fn random_balanced_journals_preserve_invariants() { c.rent, ]; - for i in 0..300u32 { - let total = rng.next(100_000) + 100; - let nd = (rng.next(3) + 1) as usize; - let nc = (rng.next(3) + 1) as usize; - let id = ledger.create_entry(date(21), &format!("random {i}")); - for part in split_total(&mut rng, total, nd) { - let acct = accounts[rng.next(accounts.len() as u64) as usize]; - ledger.add_line(id, acct, Side::Debit, usd(part)).unwrap(); + // As replay posts them: random lines land on receivables and + // payables by hand, as books an earlier version wrote can hold them + // (the control guard, rules.md B-38, refuses them as new work). + ledger_core::parties::replaying(|| { + for i in 0..300u32 { + let total = rng.next(100_000) + 100; + let nd = (rng.next(3) + 1) as usize; + let nc = (rng.next(3) + 1) as usize; + let id = ledger.create_entry(date(21), &format!("random {i}")); + for part in split_total(&mut rng, total, nd) { + let acct = accounts[rng.next(accounts.len() as u64) as usize]; + ledger.add_line(id, acct, Side::Debit, usd(part)).unwrap(); + } + for part in split_total(&mut rng, total, nc) { + let acct = accounts[rng.next(accounts.len() as u64) as usize]; + ledger.add_line(id, acct, Side::Credit, usd(part)).unwrap(); + } + ledger.post_entry(id).unwrap(); } - for part in split_total(&mut rng, total, nc) { - let acct = accounts[rng.next(accounts.len() as u64) as usize]; - ledger.add_line(id, acct, Side::Credit, usd(part)).unwrap(); - } - ledger.post_entry(id).unwrap(); - } + }); // Every invariant still holds after 300 random balanced entries. let tb = trial_balance(&ledger); @@ -1484,27 +1495,61 @@ fn a_period_ending_inside_a_closed_period_cannot_close() { } /// Replay (for journaling stores) rebuilds invoice and bill records -/// exactly: ids follow the allocator, payments accumulate, vendors tag -/// the entries. A record replayed out of order is refused. +/// exactly: ids follow the allocator, each record tags its entry's control +/// line and each payment its entry's (rules.md B-38), vendors tag the +/// entries. A record replayed out of order is refused. #[test] fn replayed_documents_and_payments_rebuild_the_records() { use ledger_core::DocumentStatus; let mut l = books_with("2000", "Accounts Payable", AccountType::Liability, false); + let cash = l.chart().find_by_code("1000").unwrap().id; + let ap = l.chart().find_by_code("2000").unwrap().id; + let ar = l + .add_account( + "1200", + "Accounts Receivable", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let revenue = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let expense = l + .add_account( + "6999", + "General Expenses", + AccountType::Expense, + None, + false, + ) + .unwrap(); + // An entry as replay rebuilds it from its own events. + let entry = |l: &mut Ledger, dr, cr, n: u64| { + let e = l.create_entry(date(5), "replayed"); + l.replay_add_line(e, dr, Side::Debit, usd(n)).unwrap(); + l.replay_add_line(e, cr, Side::Credit, usd(n)).unwrap(); + l.replay_post_entry(e).unwrap(); + e + }; let c = l.add_customer("Acme").unwrap(); let v = l.add_vendor("Vend").unwrap(); let inv = l.next_invoice_id(); + let e = entry(&mut l, ar, revenue, 100); assert_eq!( - l.replay_invoice(inv + 1, c, date(5), date(30), doc(100), 10), + l.replay_invoice(inv + 1, c, date(5), date(30), doc(100), e), Err(LedgerError::ReplayIdMismatch(inv + 1, inv)) ); - l.replay_invoice(inv, c, date(5), date(30), doc(100), 10) + l.replay_invoice(inv, c, date(5), date(30), doc(100), e) .unwrap(); // Each later record replays at the id the allocator now predicts. let mut ids = vec![inv]; - for entry in [13, 14] { + for _ in 0..2 { let next = l.next_invoice_id(); - l.replay_invoice(next, c, date(5), date(30), doc(1), entry) + let e = entry(&mut l, ar, revenue, 1); + l.replay_invoice(next, c, date(5), date(30), doc(1), e) .unwrap(); ids.push(next); } @@ -1512,38 +1557,52 @@ fn replayed_documents_and_payments_rebuild_the_records() { ids.dedup(); assert_eq!(ids.len(), 3); assert!(ids.iter().all(|&i| l.get_invoice(i).is_some())); - l.replay_invoice_payment(inv, Decimal::from(30), 11) - .unwrap(); - l.replay_invoice_payment(inv, Decimal::from(20), 12) - .unwrap(); + for n in [30, 20] { + let p = entry(&mut l, cash, ar, n); + l.replay_invoice_payment(inv, Decimal::from(n), p).unwrap(); + assert_eq!( + l.line_doc(p, 1).map(|t| t.doc), + Some(ledger_core::DocRef::Invoice(inv)) + ); + } let i = l.get_invoice(inv).unwrap(); assert_eq!(i.amount_paid, Decimal::from(50)); assert_eq!(i.balance().unwrap(), Decimal::from(50)); assert_eq!(i.status, DocumentStatus::Partial); + let st = l.invoice_state(inv, date(31)).unwrap().unwrap(); + assert_eq!( + (st.paid, st.outstanding), + (Decimal::from(50), Decimal::from(50)) + ); let bill = l.next_bill_id(); + let e = entry(&mut l, expense, ap, 100); assert_eq!( - l.replay_bill(bill + 1, v, date(5), date(30), doc(100), 20), + l.replay_bill(bill + 1, v, date(5), date(30), doc(100), e), Err(LedgerError::ReplayIdMismatch(bill + 1, bill)) ); - assert_eq!(l.entry_vendor(20), None); - l.replay_bill(bill, v, date(5), date(30), doc(100), 20) + assert_eq!(l.entry_vendor(e), None); + l.replay_bill(bill, v, date(5), date(30), doc(100), e) .unwrap(); - assert_eq!(l.entry_vendor(20), Some(v)); + assert_eq!(l.entry_vendor(e), Some(v)); let mut ids = vec![bill]; - for entry in [23, 24] { + for _ in 0..2 { let next = l.next_bill_id(); - l.replay_bill(next, v, date(5), date(30), doc(1), entry) + let e = entry(&mut l, expense, ap, 1); + l.replay_bill(next, v, date(5), date(30), doc(1), e) .unwrap(); ids.push(next); } ids.sort(); ids.dedup(); assert_eq!(ids.len(), 3); - l.replay_bill_payment(bill, Decimal::from(30), 21).unwrap(); - l.replay_bill_payment(bill, Decimal::from(70), 22).unwrap(); + let mut last = 0; + for n in [30, 70] { + last = entry(&mut l, ap, cash, n); + l.replay_bill_payment(bill, Decimal::from(n), last).unwrap(); + } let b = l.get_bill(bill).unwrap(); assert_eq!(b.amount_paid, Decimal::from(100)); assert_eq!(b.status, DocumentStatus::Paid); - assert_eq!(l.entry_vendor(22), Some(v)); + assert_eq!(l.entry_vendor(last), Some(v)); } diff --git a/crates/ledger-core/tests/model.rs b/crates/ledger-core/tests/model.rs index c4dc279..029b31b 100644 --- a/crates/ledger-core/tests/model.rs +++ b/crates/ledger-core/tests/model.rs @@ -360,7 +360,10 @@ impl Model { // Commands // --------------------------------------------------------------------------- -const CODES: [&str; 7] = ["1000", "2000", "3000", "3999", "4000", "6000", "1900"]; +// No 1200 or 2000: a line by hand on a receivables or payables account is +// refused (the control guard, rules.md B-38), which this model leaves to +// spec_arap.rs. +const CODES: [&str; 7] = ["1000", "2100", "3000", "3999", "4000", "6000", "1900"]; /// New names: a free one, its own in other capitals and spacing, a /// sibling's (the accounts are named for their codes), blank, nine /// digits, the engine's own name for retained earnings. diff --git a/crates/ledger-core/tests/spec_arap.rs b/crates/ledger-core/tests/spec_arap.rs index 093497c..bd00892 100644 --- a/crates/ledger-core/tests/spec_arap.rs +++ b/crates/ledger-core/tests/spec_arap.rs @@ -173,9 +173,10 @@ fn net_for(lines: &[(AccountId, Side, Decimal)], account: AccountId) -> Decimal // ------------------------------------------------- Invoice / Bill methods /// Oracle: Invoice doc: total() is "Total of all lines", balance() is -/// "Amount still owed" = total - amount_paid. +/// "Amount still owed" (its `owed`, derived by the ledger beside +/// amount_paid: total - amount_paid while it stands). #[test] -fn invoice_total_is_sum_of_lines_and_balance_is_total_minus_paid() { +fn invoice_total_is_sum_of_lines_and_balance_is_what_is_owed() { let inv = Invoice { id: 7, customer_id: 1, @@ -183,6 +184,7 @@ fn invoice_total_is_sum_of_lines_and_balance_is_total_minus_paid() { due_date: d(2026, 3, 31), lines: vec![line("a", "100.10"), line("b", "0.05"), line("c", "49.85")], amount_paid: dec("60.00"), + owed: dec("90.00"), status: DocumentStatus::Partial, entry_id: 1, }; @@ -190,9 +192,9 @@ fn invoice_total_is_sum_of_lines_and_balance_is_total_minus_paid() { assert_eq!(inv.balance().unwrap(), dec("90.00")); } -/// Oracle: Bill doc: total() is the sum of lines, balance() total - paid. +/// Oracle: Bill doc: total() is the sum of lines, balance() what is owed. #[test] -fn bill_total_is_sum_of_lines_and_balance_is_total_minus_paid() { +fn bill_total_is_sum_of_lines_and_balance_is_what_is_owed() { let bill = Bill { id: 3, vendor_id: 2, @@ -200,6 +202,7 @@ fn bill_total_is_sum_of_lines_and_balance_is_total_minus_paid() { due_date: d(2026, 3, 1), lines: vec![line("x", "19.99"), line("y", "0.01")], amount_paid: dec("20.00"), + owed: Decimal::ZERO, status: DocumentStatus::Paid, entry_id: 9, }; diff --git a/crates/ledger-core/tests/spec_bounds.rs b/crates/ledger-core/tests/spec_bounds.rs index 9394644..6d9d7a8 100644 --- a/crates/ledger-core/tests/spec_bounds.rs +++ b/crates/ledger-core/tests/spec_bounds.rs @@ -605,9 +605,10 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() { ); } -/// A replayed payment is as journaled: no bound but exact arithmetic (a -/// paid amount past it is refused, never wrapped). The command's bound is -/// the balance (Overpayment). +/// A replayed payment is as journaled: no bound (rules.md B-28), and no +/// refusal: it tags the line of its entry that carries it, or (an entry +/// it doesn't match) nothing. The command's bound is what the invoice +/// owes (Overpayment). #[test] fn replayed_payments_are_as_journaled() { let mut b = books(); @@ -630,24 +631,19 @@ fn replayed_payments_are_as_journaled() { .receive_payment(invoice, d(2026, 1, 15), too_big(), b.cash), Err(LedgerError::Overpayment(..)) )); + let before = b.ledger.clone(); b.ledger .replay_invoice_payment(invoice, too_big(), 98) .unwrap(); b.ledger .replay_invoice_payment(invoice, Decimal::MAX - too_big(), 99) .unwrap(); + // No entry 98 or 99 carries them: nothing is tagged, nothing paid. + let doc = ledger_core::DocRef::Invoice(invoice); + assert_eq!(b.ledger.doc_lines(doc), before.doc_lines(doc)); assert_eq!( - b.ledger.get_invoice(invoice).unwrap().amount_paid, - Decimal::MAX - ); - assert!(matches!( - b.ledger.replay_invoice_payment(invoice, Decimal::ONE, 100), - Err(LedgerError::TooLargeToCompute(_)) - )); - // Refused before anything changed. - assert_eq!( - b.ledger.get_invoice(invoice).unwrap().amount_paid, - Decimal::MAX + b.ledger.get_invoice(invoice).unwrap().balance(), + Ok(Decimal::new(10000, 2)) ); } diff --git a/crates/ledger-core/tests/spec_cash_flow.rs b/crates/ledger-core/tests/spec_cash_flow.rs index acfdd10..eea05d0 100644 --- a/crates/ledger-core/tests/spec_cash_flow.rs +++ b/crates/ledger-core/tests/spec_cash_flow.rs @@ -50,15 +50,21 @@ impl Books { } fn post(&mut self, date: NaiveDate, desc: &str, lines: &[(&str, Side, &str)]) -> EntryId { - let e = self.l.create_entry(date, desc); - for &(code, side, amount) in lines { - let a = self.id(code); - self.l - .add_line(e, a, side, Money::new(x(amount), Currency::USD)) - .unwrap(); - } - self.l.post_entry(e).unwrap(); - e + // As replay posts it: these books hold hand lines on receivables + // or payables (1200, 2000), which books an earlier version wrote + // still hold and every report must read; the control guard + // (rules.md B-38) refuses them as new work. + ledger_core::parties::replaying(|| { + let e = self.l.create_entry(date, desc); + for &(code, side, amount) in lines { + let a = self.id(code); + self.l + .add_line(e, a, side, Money::new(x(amount), Currency::USD)) + .unwrap(); + } + self.l.post_entry(e).unwrap(); + e + }) } fn reverse(&mut self, e: EntryId, date: NaiveDate) -> EntryId { diff --git a/crates/ledger-core/tests/spec_document_projects.rs b/crates/ledger-core/tests/spec_document_projects.rs index fae39b8..17acaa1 100644 --- a/crates/ledger-core/tests/spec_document_projects.rs +++ b/crates/ledger-core/tests/spec_document_projects.rs @@ -201,10 +201,15 @@ fn reversing_a_tagged_document_takes_its_projects_to_zero() { (t.revenue, t.expenses) }; assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000))); - b.l.receive_payment(inv, d(2026, 3, 20), cents(100_000), b.cash) - .unwrap(); + let pay = + b.l.receive_payment(inv, d(2026, 3, 20), cents(100_000), b.cash) + .unwrap(); assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000))); assert_eq!(to_date(&b.l, h), (cents(20_000), Decimal::ZERO)); + // The payment first: a void waits on the payments that stand (B-38 + // scenario 3); its reversal moves no project figure either. + b.l.reverse_entry(pay, d(2026, 3, 30), "Bounced").unwrap(); + assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000))); for e in [ b.l.get_invoice(inv).unwrap().entry_id, b.l.get_bill(bill).unwrap().entry_id, diff --git a/crates/ledger-core/tests/spec_documents.rs b/crates/ledger-core/tests/spec_documents.rs new file mode 100644 index 0000000..e9fc79c --- /dev/null +++ b/crates/ledger-core/tests/spec_documents.rs @@ -0,0 +1,1404 @@ +//! Documents read their figures from the ledger lines tagged with them +//! (rules.md B-38; plan P4b S1, D-45): one test per scenario of the +//! B-38 spec block, then the properties. +//! +//! ```text +//! GOAL: invoices and bills are the subledgers of the receivables and +//! payables control accounts; every figure about a document is read +//! from the ledger lines tagged with it. +//! SOURCE: docs/spec/rules.md B-8, B-9, B-10, B-18, B-23, B-28, B-38; +//! ASC 310-10, ASC 405-20-40-1, ASC 606-10-45-4. +//! ``` + +use chrono::NaiveDate; +use ledger_core::arap::DocumentLine; +use ledger_core::{ + AccountId, AccountType, Currency, DocRef, DocStatus, Ledger, LedgerError, Money, Movement, + PaymentDetails, Side, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn usd(c: i64) -> Money { + Money::new(cents(c), Currency::USD) +} + +fn line(what: &str, c: i64) -> Vec { + vec![DocumentLine { + description: what.into(), + amount: cents(c), + class_id: None, + }] +} + +/// Bank 1000, a card 2050, capital 3000, revenue 4000 (made by the first +/// invoice), and a customer and a vendor. +struct Books { + l: Ledger, + bank: AccountId, + card: AccountId, + capital: AccountId, + customer: u64, + vendor: u64, +} + +fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let bank = l + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let card = l + .add_account("2050", "Card", AccountType::Liability, None, false) + .unwrap(); + let capital = l + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let customer = l.add_customer("Fernhill Labs").unwrap(); + let vendor = l.add_vendor("Tobin Harlow Supply").unwrap(); + Books { + l, + bank, + card, + capital, + customer, + vendor, + } +} + +impl Books { + fn invoice(&mut self, on: NaiveDate, c: i64) -> u64 { + self.l + .create_invoice( + self.customer, + on, + on + chrono::Days::new(30), + line("Work", c), + ) + .unwrap() + } + + fn bill(&mut self, on: NaiveDate, c: i64) -> u64 { + self.l + .create_bill( + self.vendor, + on, + on + chrono::Days::new(30), + line("Paper", c), + ) + .unwrap() + } + + fn state(&self, doc: DocRef, on: NaiveDate) -> ledger_core::DocState { + self.l.doc_state(doc, on).unwrap().expect("issued by then") + } + + fn account(&self, code: &str) -> AccountId { + self.l.chart().find_by_code(code).unwrap().id + } + + /// Every control account's tie-out holds: no difference (B-38 + /// INVARIANT 1). + fn ties(&self, on: NaiveDate) { + for c in self.l.control_check(on).unwrap() { + assert_eq!(c.difference, Decimal::ZERO, "{c:?}"); + } + } +} + +/// Scenario 1: invoice 100, payment 100, payment reversed: Open, owing +/// 100, A/R 100. +#[test] +fn reversing_a_payment_reopens_the_invoice() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let pay = + b.l.receive_payment(inv, d(2026, 1, 20), cents(10_000), b.bank) + .unwrap(); + let doc = DocRef::Invoice(inv); + assert_eq!(b.state(doc, d(2026, 1, 21)).status, DocStatus::Paid); + b.l.reverse_entry(pay, d(2026, 1, 25), "Bounced").unwrap(); + let st = b.state(doc, d(2026, 1, 31)); + assert_eq!(st.status, DocStatus::Open); + assert_eq!((st.paid, st.outstanding), (Decimal::ZERO, cents(10_000))); + let ar = b.account("1200"); + assert_eq!(b.l.account_balance(ar).unwrap(), cents(10_000)); + // The record's figures follow: what is owed at the end of the books. + assert_eq!(b.l.get_invoice(inv).unwrap().balance(), Ok(cents(10_000))); + // The mirror carries the document: the payment and its reversal. + let lines = b.l.doc_lines(doc); + assert_eq!(lines.len(), 3); + assert!( + lines + .iter() + .filter(|l| l.reversal) + .all(|l| l.movement == Movement::Payment) + ); + b.ties(d(2026, 1, 31)); +} + +/// Scenario 2: an invoice reversed with nothing paid is Void, not in the +/// aging, its id (later its number) kept. +#[test] +fn a_voided_invoice_never_ages() { + let mut b = books(); + let inv = b.invoice(d(2026, 2, 1), 20_000); + let r = + b.l.void_document(DocRef::Invoice(inv), d(2026, 2, 5), "wrong customer") + .unwrap(); + assert_eq!( + b.l.get_entry(r).unwrap().description(), + format!("Void invoice {inv}: wrong customer") + ); + let st = b.state(DocRef::Invoice(inv), d(2026, 3, 31)); + assert_eq!( + (st.status, st.outstanding), + (DocStatus::Void, Decimal::ZERO) + ); + assert!(b.l.ar_aging(d(2026, 3, 31)).unwrap().is_empty()); + assert!(b.l.get_invoice(inv).is_some(), "kept"); + // Before the void it was open, and aged. + assert_eq!( + b.state(DocRef::Invoice(inv), d(2026, 2, 4)).status, + DocStatus::Open + ); + assert_eq!( + b.l.ar_aging(d(2026, 2, 4)).unwrap()[0].current, + cents(20_000) + ); + // Void already: refused, naming the reversal. + match b + .l + .void_document(DocRef::Invoice(inv), d(2026, 2, 6), "again") + { + Err(LedgerError::InvalidVoid(why)) => assert!(why.contains(&r.to_string()), "{why}"), + other => panic!("expected InvalidVoid, got {other:?}"), + } + // Reversing the void re-instates it (B-10); voiding again reverses + // that reversal, the end of the chain. + let back = b.l.reverse_entry(r, d(2026, 2, 7), "Re-instate").unwrap(); + assert_eq!( + b.state(DocRef::Invoice(inv), d(2026, 2, 8)).status, + DocStatus::Open + ); + let again = + b.l.void_document(DocRef::Invoice(inv), d(2026, 2, 9), "duplicate") + .unwrap(); + assert_eq!(b.l.get_entry(again).unwrap().reversal_of(), Some(back)); + assert_eq!( + b.state(DocRef::Invoice(inv), d(2026, 2, 9)).status, + DocStatus::Void + ); + b.ties(d(2026, 2, 28)); +} + +/// Scenario 3: a void with a payment standing SHOULD refuse, naming the +/// payment; after the payment's reversal it is taken. +#[test] +fn void_refused_while_paid() { + let mut b = books(); + let inv = b.invoice(d(2026, 3, 1), 10_000); + let pay = + b.l.receive_payment(inv, d(2026, 3, 5), cents(4_000), b.bank) + .unwrap(); + let before = b.l.clone(); + let origin = b.l.get_invoice(inv).unwrap().entry_id; + for refused in [ + b.l.void_document(DocRef::Invoice(inv), d(2026, 3, 10), "duplicate"), + b.l.reverse_entry(origin, d(2026, 3, 10), "Void"), + ] { + match refused { + Err(LedgerError::DocumentBounds(why)) => { + assert!(why.contains(&format!("entry {pay}")), "{why}") + } + other => panic!("expected DocumentBounds, got {other:?}"), + } + assert!( + b.l.differs_from(&before).is_empty(), + "a refusal changes nothing" + ); + } + b.l.reverse_entry(pay, d(2026, 3, 9), "Refund").unwrap(); + b.l.void_document(DocRef::Invoice(inv), d(2026, 3, 10), "duplicate") + .unwrap(); + assert_eq!( + b.state(DocRef::Invoice(inv), d(2026, 3, 31)).status, + DocStatus::Void + ); + b.ties(d(2026, 3, 31)); +} + +/// Scenario 4: as of a date between a payment and its reversal, it counts +/// as paid; aging as of then ignores the later reversal. +#[test] +fn aging_as_of_ignores_a_later_reversal() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let bill = b.bill(d(2026, 1, 15), 8_000); + let p = + b.l.receive_payment(inv, d(2026, 1, 20), cents(10_000), b.bank) + .unwrap(); + let q = + b.l.pay_bill(bill, d(2026, 1, 20), cents(8_000), b.bank) + .unwrap(); + b.l.reverse_entry(p, d(2026, 1, 25), "Bounced").unwrap(); + b.l.reverse_entry(q, d(2026, 1, 28), "Paid in error") + .unwrap(); + assert!(b.l.ar_aging(d(2026, 1, 22)).unwrap().is_empty()); + assert!(b.l.ap_aging(d(2026, 1, 22)).unwrap().is_empty()); + assert!(b.l.stands_on(p, d(2026, 1, 22))); + assert!(!b.l.stands_on(p, d(2026, 1, 25))); + assert!(!b.l.stands_on(p, d(2026, 1, 19)), "not before its date"); + assert_eq!( + b.l.ar_aging(d(2026, 1, 31)).unwrap()[0].current, + cents(10_000) + ); + assert_eq!( + b.l.ap_aging(d(2026, 1, 31)).unwrap()[0].current, + cents(8_000) + ); + assert_eq!( + b.state(DocRef::Bill(bill), d(2026, 1, 27)).status, + DocStatus::Paid + ); + assert_eq!( + b.state(DocRef::Bill(bill), d(2026, 1, 28)).status, + DocStatus::Open + ); + // An overdue flag, not a status. + let st = b.state(DocRef::Invoice(inv), d(2026, 3, 1)); + assert_eq!((st.status, st.overdue_days), (DocStatus::Open, 20)); +} + +/// Scenario 5: the wrong payment reversed, the right one is accepted; +/// a payment above what is owed on any later day is still refused. +#[test] +fn a_corrected_payment_after_reversing_the_wrong_one() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let wrong = + b.l.receive_payment(inv, d(2026, 1, 20), cents(10_000), b.bank) + .unwrap(); + b.l.reverse_entry(wrong, d(2026, 1, 21), "Wrong invoice") + .unwrap(); + assert_eq!( + b.l.payments_recorded(DocRef::Invoice(inv)), + Ok(cents(10_000)) + ); + b.l.receive_payment(inv, d(2026, 1, 22), cents(10_000), b.bank) + .unwrap(); + let st = b.state(DocRef::Invoice(inv), d(2026, 1, 31)); + assert_eq!((st.status, st.paid), (DocStatus::Paid, cents(10_000))); + assert_eq!( + b.l.payments_recorded(DocRef::Invoice(inv)), + Ok(cents(20_000)) + ); + assert!(matches!( + b.l.receive_payment(inv, d(2026, 1, 23), cents(1), b.bank), + Err(LedgerError::Overpayment(..)) + )); + // A payment dated before a later one is bounded by every day after + // it: dated on the 21st, only nothing more could be paid. + let mut c = books(); + let inv = c.invoice(d(2026, 1, 10), 10_000); + c.l.receive_payment(inv, d(2026, 1, 25), cents(6_000), c.bank) + .unwrap(); + assert_eq!( + c.l.receive_payment(inv, d(2026, 1, 21), cents(5_000), c.bank), + Err(LedgerError::Overpayment(cents(5_000), cents(4_000))) + ); + c.l.receive_payment(inv, d(2026, 1, 21), cents(4_000), c.bank) + .unwrap(); +} + +/// Re-instating a payment (reversing its reversal) beside the payment that +/// replaced it would overpay: refused. Reversing a payment is always +/// taken. +#[test] +fn reinstating_a_payment_beside_its_replacement_is_refused() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let first = + b.l.receive_payment(inv, d(2026, 1, 20), cents(10_000), b.bank) + .unwrap(); + let undo = b.l.reverse_entry(first, d(2026, 1, 21), "Bounced").unwrap(); + b.l.receive_payment(inv, d(2026, 1, 22), cents(10_000), b.bank) + .unwrap(); + let before = b.l.clone(); + assert!(matches!( + b.l.reverse_entry(undo, d(2026, 1, 23), "It cleared after all"), + Err(LedgerError::DocumentBounds(_)) + )); + assert!(b.l.differs_from(&before).is_empty()); +} + +/// Scenario 6a: a hand line on receivables or payables SHOULD refuse with +/// the document tools named, when added and (a draft drawn up before the +/// role moved there) when posted; a recurring template naming one too. +#[test] +fn hand_line_on_control_refused() { + let mut b = books(); + b.invoice(d(2026, 1, 10), 10_000); + b.bill(d(2026, 1, 10), 1_000); + let (ar, ap, revenue) = (b.account("1200"), b.account("2000"), b.account("4000")); + for (account, side) in [(ar, Side::Debit), (ap, Side::Credit)] { + let e = b.l.try_create_entry(d(2026, 1, 15), "by hand").unwrap(); + match b.l.add_line(e, account, side, usd(2_500)) { + Err(LedgerError::UntaggedControlLine { account, at }) => { + assert!(account.starts_with("1200") || account.starts_with("2000")); + assert_eq!(at, format!("line 0 of entry {e}")); + } + other => panic!("expected UntaggedControlLine, got {other:?}"), + } + let err = + b.l.add_line_fx( + e, + account, + side, + Currency::new("EUR").unwrap(), + cents(100), + Decimal::ONE, + ) + .unwrap_err(); + assert!(matches!(err, LedgerError::UntaggedControlLine { .. })); + let text = LedgerError::UntaggedControlLine { + account: "1200".into(), + at: "x".into(), + } + .to_string(); + for tool in [ + "books.create_invoice", + "books.receive_payment", + "books.reverse_entry", + ] { + assert!(text.contains(tool), "{text}"); + } + } + // A draft drawn up while 1150 held no role, posted after receivables + // moved there (its balance zero): refused at the post. + let mut c = books(); + let other = + c.l.add_account("1150", "Trade Receivables", AccountType::Asset, None, false) + .unwrap(); + let e = + c.l.try_create_entry(d(2026, 1, 15), "drafted early") + .unwrap(); + c.l.add_line(e, other, Side::Debit, usd(100)).unwrap(); + c.l.add_line(e, c.capital, Side::Credit, usd(100)).unwrap(); + c.l.assign_role("receivables", Some(other)).unwrap(); + assert!(matches!( + c.l.post_entry(e), + Err(LedgerError::UntaggedControlLine { .. }) + )); + // A recurring template naming a control account. + let err = + b.l.register_recurring(ledger_core::NewRecurring { + description: "Retainer".into(), + lines: vec![ + (ar, Side::Debit, usd(1_000)), + (revenue, Side::Credit, usd(1_000)), + ], + frequency: ledger_core::Frequency::Monthly, + start: d(2026, 2, 1), + end: None, + }) + .unwrap_err(); + assert!( + matches!(err, LedgerError::UntaggedControlLine { .. }), + "{err:?}" + ); + // An account at the default code but of another type fills no role: + // lines on it are anyone's. + let mut e2 = Ledger::new(Currency::USD); + let savings = e2 + .add_account("2000", "Savings", AccountType::Asset, None, false) + .unwrap(); + let cap = e2 + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let e = e2.try_create_entry(d(2026, 1, 1), "save").unwrap(); + e2.add_line(e, savings, Side::Debit, usd(5)).unwrap(); + e2.add_line(e, cap, Side::Credit, usd(5)).unwrap(); + e2.post_entry(e).unwrap(); +} + +/// Scenario 6b: reversing a legacy hand line on receivables is accepted, +/// and what is unattached shrinks to nothing. +#[test] +fn reversing_a_legacy_hand_line_is_allowed() { + let mut b = books(); + b.invoice(d(2026, 1, 10), 10_000); + let (ar, revenue) = (b.account("1200"), b.account("4000")); + // A hand line as an earlier version wrote it: replayed. + let hand = ledger_core::parties::replaying(|| { + let e = b.l.create_entry(d(2026, 1, 15), "Hand-booked sale"); + b.l.add_line(e, ar, Side::Debit, usd(2_500)).unwrap(); + b.l.add_line(e, revenue, Side::Credit, usd(2_500)).unwrap(); + b.l.post_entry(e).unwrap(); + e + }); + let check = |l: &Ledger, on| { + l.control_check(on) + .unwrap() + .into_iter() + .find(|c| c.role == "receivables") + .unwrap() + }; + let c = check(&b.l, d(2026, 1, 31)); + assert_eq!(c.balance, cents(12_500)); + assert_eq!(c.documents_total, cents(10_000)); + assert_eq!(c.unattached.len(), 1); + assert_eq!( + (c.unattached[0].entry, c.unattached[0].amount), + (hand, cents(2_500)) + ); + assert_eq!(c.difference, Decimal::ZERO); + b.l.reverse_entry(hand, d(2026, 1, 20), "Re-enter as an invoice") + .unwrap(); + let c = check(&b.l, d(2026, 1, 31)); + assert!(c.unattached.is_empty(), "{c:?}"); + assert_eq!(c.balance, cents(10_000)); + // As of a day before the reversal, still there. + assert_eq!(check(&b.l, d(2026, 1, 16)).unattached.len(), 1); +} + +/// Scenario 8: receive_payment's deposit account (pay_bill's payer) must +/// be a bank, a card or a payable that holds no role. +#[test] +fn deposit_account_must_be_cash_like() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let bill = b.bill(d(2026, 1, 10), 1_000); + let (ar, ap, revenue) = (b.account("1200"), b.account("2000"), b.account("4000")); + let before = b.l.clone(); + for account in [ar, ap, revenue, b.capital] { + assert!(matches!( + b.l.receive_payment(inv, d(2026, 1, 20), cents(100), account), + Err(LedgerError::PaymentAccount { .. }) + )); + assert!(matches!( + b.l.pay_bill(bill, d(2026, 1, 20), cents(100), account), + Err(LedgerError::PaymentAccount { .. }) + )); + } + assert!(b.l.differs_from(&before).is_empty()); + // A card is fine for a bill. + b.l.pay_bill(bill, d(2026, 1, 20), cents(1_000), b.card) + .unwrap(); +} + +/// A reversal of an entry a document owns is never dated before it +/// (B-9; P4b.3, "Dating rules"). +#[test] +fn reversal_before_original_refused_for_documents() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let pay = + b.l.receive_payment(inv, d(2026, 1, 20), cents(100), b.bank) + .unwrap(); + assert!(matches!( + b.l.reverse_entry(pay, d(2026, 1, 19), "early"), + Err(LedgerError::DocumentReversalBeforeOriginal { .. }) + )); + assert!(matches!( + b.l.void_document(DocRef::Invoice(inv), d(2026, 1, 9), "early"), + Err(LedgerError::DocumentReversalBeforeOriginal { .. }) + )); + // The same day is fine. + b.l.reverse_entry(pay, d(2026, 1, 20), "same day").unwrap(); +} + +/// A payment's method tags its entry in the same step (D-40); its +/// reference is in the description; a bad one is refused. +#[test] +fn a_payment_carries_its_method_and_reference() { + let mut b = books(); + let bill = b.bill(d(2026, 1, 10), 1_000); + let card = + b.l.add_instrument( + ledger_core::InstrumentKind::BusinessCard, + "Visa", + Some("4242"), + None, + b.card, + ) + .unwrap(); + let details = PaymentDetails { + instrument: Some(card), + reference: Some("ch_3Nx".into()), + }; + let e = + b.l.pay_bill_with(bill, d(2026, 1, 12), cents(1_000), b.card, &details) + .unwrap(); + assert_eq!(b.l.entry_instrument(e), Some(card)); + assert_eq!( + b.l.get_entry(e).unwrap().description(), + format!("Payment on bill {bill} (ref ch_3Nx)") + ); + let inv = b.invoice(d(2026, 1, 10), 1_000); + let before = b.l.clone(); + for bad in [ + PaymentDetails { + instrument: Some(99), + reference: None, + }, + PaymentDetails { + instrument: None, + reference: Some("two\nlines".into()), + }, + PaymentDetails { + instrument: None, + reference: Some("x".repeat(201)), + }, + ] { + assert!( + b.l.receive_payment_with(inv, d(2026, 1, 12), cents(100), b.bank, &bad) + .is_err() + ); + } + assert!(b.l.differs_from(&before).is_empty()); +} + +/// The status rules (P4b.3, "Status as of D") on their own. +#[test] +fn status_follows_the_figures() { + use ledger_core::arap::doc_status; + let (t, z) = (cents(10_000), Decimal::ZERO); + assert_eq!(doc_status(false, true, t, t, z), DocStatus::Void); + assert_eq!(doc_status(false, true, t, z, z), DocStatus::Void); + assert_eq!(doc_status(false, false, t, -t, z), DocStatus::Void); + assert_eq!(doc_status(true, false, t, z, z), DocStatus::Unattached); + assert_eq!(doc_status(true, true, t, -cents(1), z), DocStatus::Overpaid); + assert_eq!( + doc_status(true, true, t, z, cents(1)), + DocStatus::WrittenOff + ); + assert_eq!(doc_status(true, true, t, z, z), DocStatus::Paid); + assert_eq!(doc_status(true, true, t, cents(1), z), DocStatus::Partial); + assert_eq!(doc_status(true, true, t, t, z), DocStatus::Open); + assert_eq!(doc_status(true, true, t, t + t, z), DocStatus::Open); + let mut keys = Vec::new(); + for s in [ + DocStatus::Open, + DocStatus::Partial, + DocStatus::Paid, + DocStatus::WrittenOff, + DocStatus::Void, + DocStatus::Overpaid, + DocStatus::Unattached, + ] { + assert!(!s.key().is_empty() && !s.label().is_empty()); + keys.push(s.key()); + } + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), 7, "each status its own key"); +} + +/// A document dated after the day asked about isn't issued yet. +#[test] +fn a_document_after_the_day_has_no_state() { + let mut b = books(); + let inv = b.invoice(d(2026, 5, 1), 100); + assert_eq!(b.l.invoice_state(inv, d(2026, 4, 30)), Ok(None)); + assert!(b.l.invoice_state(inv, d(2026, 5, 1)).unwrap().is_some()); + assert_eq!( + b.l.invoice_state(99, d(2026, 5, 1)), + Err(LedgerError::UnknownInvoice(99)) + ); + assert_eq!( + b.l.bill_state(99, d(2026, 5, 1)), + Err(LedgerError::UnknownBill(99)) + ); +} + +/// Replay tags a document's control line on the role's account as it +/// stood then: an invoice journaled before receivables moved to 1150 +/// keeps its 1200 line; a payment after, its 1150 line. +#[test] +fn role_reassigned_mid_stream_tags_the_role_then() { + let mut l = Ledger::new(Currency::USD); + let bank = l + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let old = l + .add_account( + "1200", + "Accounts Receivable", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let new = l + .add_account("1150", "Trade Receivables", AccountType::Asset, None, false) + .unwrap(); + let revenue = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let c = l.add_customer("Fernhill Labs").unwrap(); + let entry = |l: &mut Ledger, dr, cr, n| { + let e = l.create_entry(d(2026, 1, 5), "replayed"); + l.replay_add_line(e, dr, Side::Debit, usd(n)).unwrap(); + l.replay_add_line(e, cr, Side::Credit, usd(n)).unwrap(); + l.replay_post_entry(e).unwrap(); + e + }; + let e1 = entry(&mut l, old, revenue, 10_000); + l.replay_invoice(1, c, d(2026, 1, 5), d(2026, 2, 4), line("Work", 10_000), e1) + .unwrap(); + // The role moves (as an earlier version could, with a hand entry + // moving the balance), then the payment. + let moved = entry(&mut l, new, old, 10_000); + ledger_core::parties::replaying(|| l.assign_role("receivables", Some(new))).unwrap(); + let p = entry(&mut l, bank, new, 10_000); + l.replay_invoice_payment(1, cents(10_000), p).unwrap(); + assert_eq!(l.line_doc(e1, 0).unwrap().doc, DocRef::Invoice(1)); + assert_eq!(l.line_doc(p, 1).unwrap().movement, Movement::Payment); + assert_eq!(l.line_doc(moved, 0), None, "a hand line is no document's"); + let st = l.invoice_state(1, d(2026, 1, 31)).unwrap().unwrap(); + assert_eq!( + (st.status, st.outstanding), + (DocStatus::Paid, Decimal::ZERO) + ); + for c in l.control_check(d(2026, 1, 31)).unwrap() { + assert_eq!(c.difference, Decimal::ZERO, "{c:?}"); + } +} + +/// Several payment events naming one entry tag its control lines in line +/// order (the ordinal rule, P4b.3), each at its own amount. +#[test] +fn several_payment_events_on_one_entry_tag_its_lines_in_order() { + let mut l = Ledger::new(Currency::USD); + let bank = l + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let ar = l + .add_account( + "1200", + "Accounts Receivable", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let revenue = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let c = l.add_customer("Fernhill Labs").unwrap(); + for id in [1, 2] { + let e = l.create_entry(d(2026, 1, 5), "replayed"); + l.replay_add_line(e, ar, Side::Debit, usd(1_000 * id)) + .unwrap(); + l.replay_add_line(e, revenue, Side::Credit, usd(1_000 * id)) + .unwrap(); + l.replay_post_entry(e).unwrap(); + l.replay_invoice( + id as u64, + c, + d(2026, 1, 5), + d(2026, 2, 4), + line("W", 1_000 * id), + e, + ) + .unwrap(); + } + let p = l.create_entry(d(2026, 1, 9), "one deposit, two invoices"); + l.replay_add_line(p, bank, Side::Debit, usd(3_000)).unwrap(); + l.replay_add_line(p, ar, Side::Credit, usd(1_000)).unwrap(); + l.replay_add_line(p, ar, Side::Credit, usd(2_000)).unwrap(); + l.replay_post_entry(p).unwrap(); + l.replay_invoice_payment(1, cents(1_000), p).unwrap(); + l.replay_invoice_payment(2, cents(2_000), p).unwrap(); + assert_eq!(l.line_doc(p, 1).unwrap().doc, DocRef::Invoice(1)); + assert_eq!(l.line_doc(p, 2).unwrap().doc, DocRef::Invoice(2)); + for id in [1, 2] { + let st = l.invoice_state(id, d(2026, 1, 31)).unwrap().unwrap(); + assert_eq!(st.status, DocStatus::Paid, "invoice {id}"); + } + // Counted once each by the payments recorded. + assert_eq!(l.payments_recorded(DocRef::Invoice(2)), Ok(cents(2_000))); +} + +/// A void waits on the payments that stand, and names exactly those: a +/// bounced payment's reversal stands too, but re-instating the bounced +/// payment is never the remedy (review of S1, finding 1). +#[test] +fn standing_payments_never_names_a_reversal() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let doc = DocRef::Invoice(inv); + let p = + b.l.receive_payment(inv, d(2026, 1, 12), cents(4_000), b.bank) + .unwrap(); + let r = b.l.reverse_entry(p, d(2026, 1, 14), "Bounced").unwrap(); + let q = + b.l.receive_payment(inv, d(2026, 1, 16), cents(4_000), b.bank) + .unwrap(); + assert_eq!(b.l.standing_payments(doc), vec![q]); + match b.l.void_document(doc, d(2026, 1, 20), "duplicate") { + Err(LedgerError::DocumentBounds(why)) => { + assert!(why.contains(&format!("entry {q}")), "{why}"); + assert!(!why.contains(&format!("entry {r}")), "{why}"); + assert!(!why.contains(&format!("entry {p}")), "{why}"); + } + other => panic!("expected DocumentBounds, got {other:?}"), + } + // Two payments in one day, and each named once. + let q2 = + b.l.receive_payment(inv, d(2026, 1, 16), cents(1_000), b.bank) + .unwrap(); + assert_eq!(b.l.standing_payments(doc), vec![q, q2]); +} + +/// Replay tags a payment's line on the side a payment posts: an older +/// payment deposited into receivables itself (Dr 1200 / Cr 1200, plan +/// P4b.2 gap 5) pays its invoice with the credit, and the debit is no +/// document's (review of S1, finding 2). +#[test] +fn a_payment_into_the_control_account_tags_its_credit() { + let mut l = Ledger::new(Currency::USD); + let ar = l + .add_account( + "1200", + "Accounts Receivable", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let revenue = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let c = l.add_customer("Fernhill Labs").unwrap(); + let e = l.create_entry(d(2026, 1, 5), "Invoice 1"); + l.replay_add_line(e, ar, Side::Debit, usd(10_000)).unwrap(); + l.replay_add_line(e, revenue, Side::Credit, usd(10_000)) + .unwrap(); + l.replay_post_entry(e).unwrap(); + l.replay_invoice(1, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 10_000), e) + .unwrap(); + let p = l.create_entry(d(2026, 1, 9), "Payment on invoice 1"); + l.replay_add_line(p, ar, Side::Debit, usd(3_000)).unwrap(); + l.replay_add_line(p, ar, Side::Credit, usd(3_000)).unwrap(); + l.replay_post_entry(p).unwrap(); + l.replay_invoice_payment(1, cents(3_000), p).unwrap(); + assert_eq!(l.line_doc(p, 0), None, "the debit is no document's"); + assert_eq!(l.line_doc(p, 1).unwrap().movement, Movement::Payment); + let st = l.invoice_state(1, d(2026, 1, 31)).unwrap().unwrap(); + assert_eq!( + (st.paid, st.outstanding, st.status), + (cents(3_000), cents(7_000), DocStatus::Partial) + ); + let c = &l.control_check(d(2026, 1, 31)).unwrap()[0]; + assert_eq!( + (c.balance, c.documents_total, c.difference), + (cents(10_000), cents(7_000), Decimal::ZERO) + ); + assert_eq!(c.unattached.len(), 1); + assert_eq!(c.unattached[0].amount, cents(3_000)); +} + +/// The record's cached figures (what the MCP reads until it reads +/// doc_state) never call a void paid: it settled nothing and owes +/// nothing (review of S1, finding 3). +#[test] +fn a_void_never_reads_as_paid_in_the_record() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + b.l.void_document(DocRef::Invoice(inv), d(2026, 1, 11), "duplicate") + .unwrap(); + let r = b.l.get_invoice(inv).unwrap(); + assert_eq!(r.amount_paid, Decimal::ZERO); + assert_eq!(r.balance(), Ok(Decimal::ZERO)); + assert_eq!(r.status, ledger_core::DocumentStatus::Open); + assert_eq!( + b.state(DocRef::Invoice(inv), d(2026, 1, 31)).status, + DocStatus::Void + ); + // Paid in part, the rest owed: the record says so. + let bill = b.bill(d(2026, 1, 10), 1_000); + b.l.pay_bill(bill, d(2026, 1, 12), cents(400), b.bank) + .unwrap(); + let r = b.l.get_bill(bill).unwrap(); + assert_eq!( + (r.amount_paid, r.balance(), r.status), + ( + cents(400), + Ok(cents(600)), + ledger_core::DocumentStatus::Partial + ) + ); +} + +/// Receivables and payables move only to an account whose lines are all +/// documents', and never while their account holds a balance; the +/// refusal names no hand entry, which the guard refuses (review of S1: +/// first review's finding 4, second's finding 2). An account the role +/// moved off stays guarded while it holds documents' lines. +#[test] +fn moving_a_document_role_keeps_the_guard() { + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let (ar, revenue) = (b.account("1200"), b.account("4000")); + let fresh = + b.l.add_account("1150", "Trade Receivables", AccountType::Asset, None, false) + .unwrap(); + let err = b.l.assign_role("receivables", Some(fresh)).unwrap_err(); + assert!( + matches!(err, LedgerError::DocumentRoleHoldsBalance { .. }), + "{err:?}" + ); + let text = err.to_string(); + assert!(!text.contains("journal entry"), "{text}"); + assert!(text.contains("hand entry"), "{text}"); + // A hand line on the account it would move to. + let used = + b.l.add_account("1160", "Old Receivables", AccountType::Asset, None, false) + .unwrap(); + let e = b.l.try_create_entry(d(2026, 1, 3), "by hand").unwrap(); + b.l.add_line(e, used, Side::Debit, usd(500)).unwrap(); + b.l.add_line(e, revenue, Side::Credit, usd(500)).unwrap(); + b.l.post_entry(e).unwrap(); + b.l.receive_payment(inv, d(2026, 1, 12), cents(10_000), b.bank) + .unwrap(); + match b.l.assign_role("receivables", Some(used)) { + Err(LedgerError::RoleAccountHasHandLines { entry, code, .. }) => { + assert_eq!((entry, code.as_str()), (e, "1160")); + } + other => panic!("expected RoleAccountHasHandLines, got {other:?}"), + } + // Replay takes what an earlier version journaled. + let mut replayed = b.l.clone(); + ledger_core::parties::replaying(|| replayed.assign_role("receivables", Some(used))).unwrap(); + // Nothing owed now: the role moves to the fresh account, and 1200, + // holding the invoice's lines, stays guarded. + b.l.assign_role("receivables", Some(fresh)).unwrap(); + assert!(b.l.control_role_of(ar).is_some()); + let e = b.l.try_create_entry(d(2026, 1, 20), "by hand").unwrap(); + assert!(matches!( + b.l.add_line(e, ar, Side::Debit, usd(100)), + Err(LedgerError::UntaggedControlLine { .. }) + )); + // A later reversal of the old payment lands on 1200, tagged, and + // both accounts tie. + let pay = b.l.standing_payments(DocRef::Invoice(inv))[0]; + b.l.reverse_entry(pay, d(2026, 1, 21), "Bounced").unwrap(); + b.ties(d(2026, 1, 31)); +} + +/// The guard locks only accounts whose balance is documents: a chart's +/// own 1200 Equipment, or a card at 2000, is anyone's while no document +/// posts there and the role is assigned elsewhere (second review of S1, +/// finding 1). +#[test] +fn a_chart_whose_1200_is_not_receivables() { + let mut l = Ledger::new(Currency::USD); + let equipment = l + .add_account("1200", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let card = l + .add_account("2000", "Visa", AccountType::Liability, None, false) + .unwrap(); + let payables = l + .add_account( + "2010", + "Accounts Payable", + AccountType::Liability, + None, + false, + ) + .unwrap(); + l.add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + // Buying equipment by hand, on the card. + let e = l.try_create_entry(d(2026, 1, 3), "Laptop").unwrap(); + l.add_line(e, equipment, Side::Debit, usd(150_000)).unwrap(); + l.add_line(e, card, Side::Credit, usd(150_000)).unwrap(); + l.post_entry(e).unwrap(); + assert!(l.control_role_of(equipment).is_none()); + assert!(l.control_role_of(card).is_none()); + assert!(l.control_check(d(2026, 1, 31)).unwrap().is_empty()); + // Bills post to the assigned payables; the card at 2000 pays one. + l.assign_role("payables", Some(payables)).unwrap(); + let v = l.add_vendor("Tobin Harlow Supply").unwrap(); + let bill = l + .create_bill(v, d(2026, 1, 5), d(2026, 2, 4), line("Paper", 2_000)) + .unwrap(); + // Payables' own account may not pay it. + assert!(matches!( + l.pay_bill(bill, d(2026, 1, 6), cents(1), payables), + Err(LedgerError::PaymentAccount { .. }) + )); + l.pay_bill(bill, d(2026, 1, 6), cents(2_000), card).unwrap(); + assert_eq!(l.control_role_of(payables).map(|c| c.key), Some("payables")); + let checks = l.control_check(d(2026, 1, 31)).unwrap(); + assert_eq!(checks.len(), 1); + assert_eq!(checks[0].account, payables); + assert_eq!(checks[0].difference, Decimal::ZERO); +} + +/// Books an earlier version left out of bounds: a credit balance is +/// Overpaid (or Void, for a void under a payment that stands), never +/// aged as a negative receivable, and listed by the control check; a +/// document whose own line couldn't be tagged is Unattached, never Paid +/// (first review of S1, finding 6; second review's finding 9). +#[test] +fn out_of_bounds_documents_are_listed_not_aged() { + let mut l = Ledger::new(Currency::USD); + let bank = l + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let ar = l + .add_account( + "1200", + "Accounts Receivable", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let revenue = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let c = l.add_customer("Fernhill Labs").unwrap(); + let post = |l: &mut Ledger, on, lines: &[(AccountId, Side, i64)]| { + let e = l.create_entry(on, "replayed"); + for &(a, side, n) in lines { + l.replay_add_line(e, a, side, usd(n)).unwrap(); + } + l.replay_post_entry(e).unwrap(); + e + }; + ledger_core::parties::replaying(|| { + // Invoice 1, paid twice over by two payment events. + let e = post( + &mut l, + d(2026, 1, 5), + &[(ar, Side::Debit, 10_000), (revenue, Side::Credit, 10_000)], + ); + l.replay_invoice(1, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 10_000), e) + .unwrap(); + for _ in 0..2 { + let p = post( + &mut l, + d(2026, 1, 9), + &[(bank, Side::Debit, 6_000), (ar, Side::Credit, 6_000)], + ); + l.replay_invoice_payment(1, cents(6_000), p).unwrap(); + } + // Invoice 2, paid, then voided under the payment. + let e2 = post( + &mut l, + d(2026, 1, 5), + &[(ar, Side::Debit, 5_000), (revenue, Side::Credit, 5_000)], + ); + l.replay_invoice(2, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 5_000), e2) + .unwrap(); + let p = post( + &mut l, + d(2026, 1, 9), + &[(bank, Side::Debit, 5_000), (ar, Side::Credit, 5_000)], + ); + l.replay_invoice_payment(2, cents(5_000), p).unwrap(); + l.replay_reverse_entry(e2, d(2026, 1, 10), "Void").unwrap(); + // Invoice 3, whose entry posted to revenue only (no line to tag). + let e3 = post( + &mut l, + d(2026, 1, 5), + &[(bank, Side::Debit, 700), (revenue, Side::Credit, 700)], + ); + l.replay_invoice(3, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 700), e3) + .unwrap(); + // Invoice 4, owing 30.00, to show aging still counts the owed. + let e4 = post( + &mut l, + d(2026, 1, 5), + &[(ar, Side::Debit, 3_000), (revenue, Side::Credit, 3_000)], + ); + l.replay_invoice(4, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 3_000), e4) + .unwrap(); + }); + let on = d(2026, 1, 31); + let st = |id| l.invoice_state(id, on).unwrap().unwrap(); + assert_eq!( + (st(1).status, st(1).outstanding), + (DocStatus::Overpaid, -cents(2_000)) + ); + assert!(st(1).out_of_bounds()); + assert_eq!( + (st(2).status, st(2).outstanding), + (DocStatus::Void, -cents(5_000)) + ); + assert_eq!(st(3).status, DocStatus::Unattached); + assert!(!st(4).out_of_bounds()); + let aged: Vec<_> = l.ar_aging(on).unwrap(); + assert_eq!(aged.len(), 1); + assert_eq!(aged[0].total(), Ok(cents(3_000)), "{aged:?}"); + let check = &l.control_check(on).unwrap()[0]; + assert_eq!(check.difference, Decimal::ZERO); + let listed: Vec<_> = check + .attention + .iter() + .map(|a| (a.doc, a.status, a.outstanding)) + .collect(); + assert_eq!( + listed, + [ + (DocRef::Invoice(1), DocStatus::Overpaid, -cents(2_000)), + (DocRef::Invoice(2), DocStatus::Void, -cents(5_000)), + (DocRef::Invoice(3), DocStatus::Unattached, Decimal::ZERO), + ] + ); +} + +/// Reversing a legacy hand line takes it off; reversing that reversal +/// would put it back, and is refused: no command adds an untagged control +/// line (B-38 INVARIANT 1 and item 3; first review of S1). +#[test] +fn putting_back_a_removed_hand_line_is_refused() { + let mut b = books(); + b.invoice(d(2026, 1, 10), 10_000); + let (ar, revenue) = (b.account("1200"), b.account("4000")); + let hand = ledger_core::parties::replaying(|| { + let e = b.l.create_entry(d(2026, 1, 15), "Hand-booked sale"); + b.l.add_line(e, ar, Side::Debit, usd(2_500)).unwrap(); + b.l.add_line(e, revenue, Side::Credit, usd(2_500)).unwrap(); + b.l.post_entry(e).unwrap(); + e + }); + let off = b.l.reverse_entry(hand, d(2026, 1, 20), "Re-enter").unwrap(); + let before = b.l.clone(); + assert!(matches!( + b.l.reverse_entry(off, d(2026, 1, 21), "Put it back"), + Err(LedgerError::UntaggedControlLine { .. }) + )); + assert!(b.l.differs_from(&before).is_empty()); + // An earlier version's re-instatement replays, and a later reversal + // of it takes the line off again. + let back = + b.l.replay_reverse_entry(off, d(2026, 1, 21), "Put back") + .unwrap(); + b.l.reverse_entry(back, d(2026, 1, 22), "Off again") + .unwrap(); + let c = &b.l.control_check(d(2026, 1, 31)).unwrap()[0]; + assert!(c.unattached.is_empty(), "{c:?}"); +} + +/// Plan P4b.3 ("Dating rules"): an invoice from a closed period is voided +/// in an open one. As of the closed period's end it is still Open; the +/// reversal's revenue lands in the open period, the closed one's figures +/// untouched (ASC 250: an immaterial error corrected when found). +#[test] +fn a_void_of_a_closed_periods_invoice_lands_in_the_open_period() { + use ledger_core::reports::profit_and_loss_for_period; + let mut b = books(); + let inv = b.invoice(d(2026, 1, 10), 10_000); + let jan = + b.l.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) + .unwrap(); + b.l.close_period(jan).unwrap(); + let doc = DocRef::Invoice(inv); + assert!(matches!( + b.l.void_document(doc, d(2026, 1, 20), "duplicate"), + Err(LedgerError::ClosedPeriod(..)) + )); + b.l.void_document(doc, d(2026, 2, 3), "duplicate").unwrap(); + assert_eq!(b.state(doc, d(2026, 1, 31)).status, DocStatus::Open); + assert_eq!(b.state(doc, d(2026, 2, 3)).status, DocStatus::Void); + let revenue = |from, to| { + profit_and_loss_for_period(&b.l, from, to, None) + .unwrap() + .total_revenue + }; + assert_eq!(revenue(d(2026, 1, 1), d(2026, 1, 31)), cents(10_000)); + assert_eq!(revenue(d(2026, 2, 1), d(2026, 2, 28)), -cents(10_000)); + let aged = b.l.ar_aging(d(2026, 1, 31)).unwrap(); + assert_eq!(aged[0].total(), Ok(cents(10_000))); + assert!(b.l.ar_aging(d(2026, 2, 28)).unwrap().is_empty()); + b.ties(d(2026, 1, 31)); + b.ties(d(2026, 2, 28)); +} + +/// The payments an older version counts are every payment event, its +/// line tagged or not, so the document_standing gate counts what that +/// version would (second review of S1, finding 7); past exact arithmetic +/// they are refused, never a panic (finding 4). +#[test] +fn payments_recorded_counts_every_event() { + let mut l = Ledger::new(Currency::USD); + let bank = l + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let ar = l + .add_account( + "1200", + "Accounts Receivable", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let revenue = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let c = l.add_customer("Fernhill Labs").unwrap(); + let e = l.create_entry(d(2026, 1, 5), "Invoice 1"); + l.replay_add_line(e, ar, Side::Debit, usd(10_000)).unwrap(); + l.replay_add_line(e, revenue, Side::Credit, usd(10_000)) + .unwrap(); + l.replay_post_entry(e).unwrap(); + l.replay_invoice(1, c, d(2026, 1, 5), d(2026, 2, 4), line("W", 10_000), e) + .unwrap(); + // A payment event whose amount matches no line: untagged, yet an + // older version adds it. + let p = l.create_entry(d(2026, 1, 9), "Payment"); + l.replay_add_line(p, bank, Side::Debit, usd(4_000)).unwrap(); + l.replay_add_line(p, ar, Side::Credit, usd(4_000)).unwrap(); + l.replay_post_entry(p).unwrap(); + l.replay_invoice_payment(1, cents(3_000), p).unwrap(); + assert_eq!(l.line_doc(p, 1), None); + assert_eq!(l.payments_recorded(DocRef::Invoice(1)), Ok(cents(3_000))); + let huge = Decimal::MAX / Decimal::TWO + Decimal::ONE; + l.replay_invoice_payment(1, huge, p).unwrap(); + l.replay_invoice_payment(1, huge, p).unwrap(); + assert!(matches!( + l.payments_recorded(DocRef::Invoice(1)), + Err(LedgerError::TooLargeToCompute(_)) + )); +} + +/// stands_on counts a reversal chain in order: an older journal's +/// re-reversal dated before the reversal it undoes counts only from that +/// reversal's date (second review of S1, finding 9). +#[test] +fn a_chain_counts_in_order() { + let mut b = books(); + let e = b.l.try_create_entry(d(2026, 1, 1), "Capital in").unwrap(); + b.l.add_line(e, b.bank, Side::Debit, usd(100)).unwrap(); + b.l.add_line(e, b.capital, Side::Credit, usd(100)).unwrap(); + b.l.post_entry(e).unwrap(); + let r = b.l.reverse_entry(e, d(2026, 1, 20), "undo").unwrap(); + let rr = b.l.replay_reverse_entry(r, d(2026, 1, 10), "redo").unwrap(); + assert!(b.l.stands_on(e, d(2026, 1, 15)), "nothing reversed it yet"); + assert!(!b.l.stands_on(r, d(2026, 1, 15))); + assert!(b.l.stands_on(e, d(2026, 1, 25))); + assert!(!b.l.stands_on(r, d(2026, 1, 25))); + assert!(b.l.stands_on(rr, d(2026, 1, 25))); +} + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +/// One step of a random history. +#[derive(Clone, Debug)] +enum Op { + /// An invoice (true) or a bill, on day `n`, for `c` cents. + Create(bool, u8, i64), + /// A payment on document `k` (mod the documents) on day `n`. + Pay(usize, u8, i64), + /// Reverse entry `k` (mod the posted entries) on day `n`. + Reverse(usize, u8), + /// Void document `k` on day `n`. + Void(usize, u8), + /// A hand line on receivables (true) or payables: always refused. + Hand(bool, i64), +} + +fn arb_op() -> impl Strategy { + prop_oneof![ + 3 => (any::(), 0u8..60, 1i64..50_000).prop_map(|(i, n, c)| Op::Create(i, n, c)), + 4 => (0usize..8, 0u8..90, 1i64..60_000).prop_map(|(k, n, c)| Op::Pay(k, n, c)), + 3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)), + 1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)), + 1 => (any::(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)), + ] +} + +/// Run `ops`, checking after every step that a refusal changed nothing; +/// returns the books and the documents made. +fn run(ops: &[Op]) -> (Books, Vec) { + let mut b = books(); + let mut docs: Vec = Vec::new(); + let day = |n: u8| d(2026, 1, 1) + chrono::Days::new(u64::from(n)); + for op in ops { + let before = b.l.clone(); + let result: Result<(), LedgerError> = match *op { + Op::Create(true, n, c) => { + b.l.create_invoice(b.customer, day(n), day(n), line("w", c)) + .map(|i| docs.push(DocRef::Invoice(i))) + } + Op::Create(false, n, c) => { + b.l.create_bill(b.vendor, day(n), day(n), line("w", c)) + .map(|i| docs.push(DocRef::Bill(i))) + } + Op::Pay(_, _, _) if docs.is_empty() => Ok(()), + Op::Pay(k, n, c) => match docs[k % docs.len()] { + DocRef::Invoice(i) => b.l.receive_payment(i, day(n), cents(c), b.bank).map(|_| ()), + DocRef::Bill(i) => b.l.pay_bill(i, day(n), cents(c), b.bank).map(|_| ()), + }, + Op::Reverse(k, n) => { + let posted: Vec = b.l.posted_entries().map(|e| e.id()).collect(); + match posted.get(k % posted.len().max(1)) { + Some(&e) => b.l.reverse_entry(e, day(n), "undo").map(|_| ()), + None => Ok(()), + } + } + Op::Void(_, _) if docs.is_empty() => Ok(()), + Op::Void(k, n) => { + b.l.void_document(docs[k % docs.len()], day(n), "void") + .map(|_| ()) + } + Op::Hand(receivables, c) => { + let code = if receivables { "1200" } else { "2000" }; + match b.l.chart().find_by_code(code).map(|a| a.id) { + Some(a) => { + let e = b.l.try_create_entry(day(0), "hand").unwrap(); + let r = b.l.add_line(e, a, Side::Debit, usd(c)); + assert!( + matches!(r, Err(LedgerError::UntaggedControlLine { .. })), + "{r:?}" + ); + b.l = before.clone(); + Err(LedgerError::EmptyDocumentLines) + } + None => Ok(()), + } + } + }; + if result.is_err() { + assert!( + b.l.differs_from(&before).is_empty(), + "{op:?} refused, yet changed the books" + ); + } + } + (b, docs) +} + +/// Every day a figure could change on, and the end. +fn days(b: &Books) -> Vec { + let mut v: Vec = b.l.posted_entries().map(|e| e.date()).collect(); + v.push(d(2026, 12, 31)); + v.sort(); + v.dedup(); + v +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// B-38 INVARIANTS 1, 2 and 6 over random histories of creates, + /// payments, voids, reversals of any entry and refused hand lines: at + /// every date the control accounts equal their documents (no + /// difference, nothing unattached: no command adds a hand line), each + /// document owes between nothing and its total, its own status agrees + /// with its figures, and every refusal left the books unchanged. + #[test] + fn subledger_equals_control(ops in prop::collection::vec(arb_op(), 0..40)) { + let (b, docs) = run(&ops); + for on in days(&b) { + let checks = b.l.control_check(on).unwrap(); + for c in &checks { + prop_assert_eq!(c.difference, Decimal::ZERO); + prop_assert!(c.unattached.is_empty(), "{:?}", c); + } + for doc in &docs { + let Some(st) = b.l.doc_state(*doc, on).unwrap() else { continue }; + prop_assert!(st.outstanding >= Decimal::ZERO && st.outstanding <= st.total, + "{doc} on {on}: {st:?}"); + prop_assert_eq!(st.outstanding, st.total * Decimal::from( + u8::from(st.status != DocStatus::Void)) - st.paid); + } + // The control accounts' balances are the documents' sums. + let owed = |inv: bool| -> Decimal { + docs.iter() + .filter(|d| matches!(d, DocRef::Invoice(_)) == inv) + .filter_map(|d| b.l.doc_state(*d, on).unwrap()) + .map(|s| s.outstanding) + .sum() + }; + for c in &checks { + prop_assert_eq!(c.balance, owed(c.role == "receivables")); + } + } + } + + /// B-38 INVARIANT 5: the aging as of a day partitions what the + /// documents owe then (in books these commands write, none owes less + /// than nothing). INVARIANT 4 (Store, replay and Snapshot agree) is + /// ledger-store's `status_is_a_function_of_journal_and_date`. + #[test] + fn aging_partitions_what_documents_owe(ops in prop::collection::vec(arb_op(), 0..30)) { + let (a, docs) = run(&ops); + for on in days(&a) { + let (mut ar, mut ap) = (Decimal::ZERO, Decimal::ZERO); + for doc in &docs { + let Some(st) = a.l.doc_state(*doc, on).unwrap() else { continue }; + match doc { + DocRef::Invoice(_) => ar += st.outstanding, + DocRef::Bill(_) => ap += st.outstanding, + } + } + let aged = |rows: Vec| -> Decimal { + rows.iter().map(|r| r.total().unwrap()).sum() + }; + prop_assert_eq!(aged(a.l.ar_aging(on).unwrap()), ar); + prop_assert_eq!(aged(a.l.ap_aging(on).unwrap()), ap); + } + } + + /// B-38 INVARIANT 3: reversing a tagged entry moves its documents by + /// exactly the opposite of the entry's own tagged lines from the + /// reversal's day (read from the entry reversed, not from the mirror, + /// so a mirror that lost its tag is caught); reversing that reversal + /// restores them. Reversing a payment that stands is always accepted + /// (P4b.3, Bounds), so the property is never vacuous on payments. + #[test] + fn reversal_twice_restores_state(ops in prop::collection::vec(arb_op(), 1..30), k in 0usize..40) { + let (mut b, docs) = run(&ops); + let posted: Vec = b.l.posted_entries().map(|e| e.id()).collect(); + prop_assume!(!posted.is_empty()); + let e = posted[k % posted.len()]; + let (earlier, later) = (d(2026, 10, 31), d(2026, 11, 1)); + let state = |b: &Books, on| -> Vec<_> { + docs.iter().map(|doc| b.l.doc_state(*doc, on).unwrap()).collect() + }; + let (before_earlier, before) = (state(&b, earlier), state(&b, later)); + let original: Vec = docs + .iter() + .map(|doc| b.l.doc_lines(*doc).iter().filter(|l| l.entry == e).map(|l| l.amount).sum()) + .collect(); + let unreversed = b.l.reversal_chain(e).last() == Some(&e); + let a_payment = b.l.get_entry(e).is_some_and(|x| x.reversal_of().is_none()) + && docs.iter().any(|doc| b.l.doc_lines(*doc).iter() + .any(|l| l.entry == e && l.movement == Movement::Payment)); + let reversed = b.l.reverse_entry(e, later, "undo"); + if a_payment && unreversed { + prop_assert!(reversed.is_ok(), "reversing payment {e}: {reversed:?}"); + } + let Ok(r) = reversed else { return Ok(()) }; + let moved = state(&b, later); + // Nothing moves before the reversal's day. + prop_assert_eq!(state(&b, earlier), before_earlier); + for i in 0..docs.len() { + let (Some(x), Some(y)) = (before[i], moved[i]) else { continue }; + prop_assert_eq!(y.outstanding - x.outstanding, -original[i]); + } + if b.l.reverse_entry(r, d(2026, 11, 2), "redo").is_ok() { + for (i, doc) in docs.iter().enumerate() { + let now = b.l.doc_state(*doc, d(2026, 11, 2)).unwrap(); + prop_assert_eq!(now.map(|s| (s.outstanding, s.status)), before[i].map(|s| (s.outstanding, s.status))); + } + } + } +} diff --git a/crates/ledger-core/tests/spec_ledger.rs b/crates/ledger-core/tests/spec_ledger.rs index 58f6795..004ddcc 100644 --- a/crates/ledger-core/tests/spec_ledger.rs +++ b/crates/ledger-core/tests/spec_ledger.rs @@ -2468,7 +2468,7 @@ proptest! { let mut l = Ledger::new(Currency::USD); let ids: Vec<_> = [ ("1000", AccountType::Asset), - ("2000", AccountType::Liability), + ("2100", AccountType::Liability), ("4000", AccountType::Revenue), ("6100", AccountType::Expense), ] @@ -2564,7 +2564,7 @@ proptest! { let mut l = Ledger::new(Currency::USD); let ids: Vec<_> = [ ("1000", AccountType::Asset), - ("2000", AccountType::Liability), + ("2100", AccountType::Liability), ("4000", AccountType::Revenue), ("6100", AccountType::Expense), ] diff --git a/crates/ledger-core/tests/spec_projects.rs b/crates/ledger-core/tests/spec_projects.rs index ed09917..a51b766 100644 --- a/crates/ledger-core/tests/spec_projects.rs +++ b/crates/ledger-core/tests/spec_projects.rs @@ -28,7 +28,7 @@ fn usd(c: i64) -> Money { Money::new(cents(c), Currency::USD) } -/// Cash 1000, equipment 1200, revenue 4000, contractors 6000, rent 6200. +/// Cash 1000, equipment 1500, revenue 4000, contractors 6000, rent 6200. struct Books { l: Ledger, cash: u64, @@ -42,7 +42,7 @@ fn books() -> Books { let mut l = Ledger::new(Currency::USD); let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let cash = add("1000", AccountType::Asset); - let equipment = add("1200", AccountType::Asset); + let equipment = add("1500", AccountType::Asset); let revenue = add("4000", AccountType::Revenue); let contractors = add("6000", AccountType::Expense); let rent = add("6200", AccountType::Expense); diff --git a/crates/ledger-core/tests/spec_replayed_extremes.rs b/crates/ledger-core/tests/spec_replayed_extremes.rs index 4b1b472..900f42f 100644 --- a/crates/ledger-core/tests/spec_replayed_extremes.rs +++ b/crates/ledger-core/tests/spec_replayed_extremes.rs @@ -283,22 +283,43 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() { assert!(why.contains("too large to compute"), "{why}"); } -/// A document is restored as journaled: its total and balance are exact, -/// or refused past exact arithmetic; aging sums by party, checked. +/// A document is restored as journaled: its total is exact, or refused +/// past exact arithmetic; what it owes is what the ledger lines tagged +/// with it carry (rules.md B-38), exact, whatever the journal held (paid +/// above its total included); aging refuses a document whose total it +/// can't compute. #[test] fn documents_and_aging_are_exact_or_refused() { let mut l = Ledger::new(Currency::USD); + let ar = l + .add_account("1200", "Receivables", AccountType::Asset, None, false) + .unwrap(); + let revenue = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); let customer = l.add_customer("Client").unwrap(); let line = |a: Decimal| DocumentLine { description: "x".into(), amount: a, class_id: None, }; + // An entry as replay rebuilds it: Dr `dr` / Cr `cr` for `a`. + let replayed = |l: &mut Ledger, dr, cr, a: Decimal| { + let e = l.create_entry(d(2026, 3, 1), "replayed"); + l.replay_add_line(e, dr, Side::Debit, usd(a)).unwrap(); + l.replay_add_line(e, cr, Side::Credit, usd(a)).unwrap(); + l.replay_post_entry(e).unwrap(); + e + }; let fine = vec![ line(Decimal::new(1, 11)), line("9.99999999999".parse().unwrap()), ]; - l.replay_invoice(1, customer, d(2026, 3, 1), d(2026, 3, 31), fine, 1) + let e1 = replayed(&mut l, ar, revenue, Decimal::TEN); + l.replay_invoice(1, customer, d(2026, 3, 1), d(2026, 3, 31), fine, e1) .unwrap(); let inv = l.get_invoice(1).unwrap(); assert_eq!(inv.total(), Ok(Decimal::TEN)); @@ -307,8 +328,9 @@ fn documents_and_aging_are_exact_or_refused() { // 62 days past due on June 1st: 61-90. assert_eq!(rows[0].days_90, Decimal::TEN); assert_eq!(rows[0].total(), Ok(Decimal::TEN)); - // Two documents of half Decimal's largest each: each is exact, the - // party's aging bucket is not. + // Two documents of half Decimal's largest each, their totals exact; no + // line carries them (no entry could), so they owe nothing and age + // nowhere. let half = Decimal::MAX / Decimal::TWO; for id in [2, 3] { l.replay_invoice( @@ -317,40 +339,44 @@ fn documents_and_aging_are_exact_or_refused() { d(2026, 3, 1), d(2026, 3, 31), vec![line(half)], - id, + 100 + id, ) .unwrap(); assert_eq!(l.get_invoice(id).unwrap().total(), Ok(half)); + let st = l.invoice_state(id, d(2026, 6, 1)).unwrap().unwrap(); + assert_eq!(st.outstanding, Decimal::ZERO); } - assert!(too_large(l.ar_aging(d(2026, 6, 1)))); + assert_eq!( + l.ar_aging(d(2026, 6, 1)).unwrap()[0].total(), + Ok(Decimal::TEN) + ); // Paid more than its total, as journaled: a negative balance, exact. - l.replay_invoice_payment(1, Decimal::from(11), 9).unwrap(); + let p = replayed(&mut l, cash, ar, Decimal::from(11)); + l.replay_invoice_payment(1, Decimal::from(11), p).unwrap(); assert_eq!(l.get_invoice(1).unwrap().balance(), Ok(-Decimal::ONE)); - // A total of lines that overflow, and its balance. + let st = l.invoice_state(1, d(2026, 6, 1)).unwrap().unwrap(); + assert_eq!( + (st.paid, st.outstanding), + (Decimal::from(11), -Decimal::ONE) + ); + // A payment event naming an amount no line of its entry carries tags + // nothing, and is refused nothing (B-28). + l.replay_invoice_payment(1, -Decimal::ONE, p).unwrap(); + assert_eq!(l.get_invoice(1).unwrap().balance(), Ok(-Decimal::ONE)); + // A total of lines that overflow, and its balance; aging refuses it. l.replay_invoice( 4, customer, d(2026, 3, 1), d(2026, 3, 31), vec![line(Decimal::MAX), line(Decimal::MAX)], - 4, + 104, ) .unwrap(); assert!(too_large(l.get_invoice(4).unwrap().total())); assert!(too_large(l.get_invoice(4).unwrap().balance())); - // A balance past exact arithmetic: a total of Decimal's largest paid - // a negative amount. - l.replay_invoice( - 5, - customer, - d(2026, 3, 1), - d(2026, 3, 31), - vec![line(Decimal::MAX)], - 5, - ) - .unwrap(); - l.replay_invoice_payment(5, -Decimal::ONE, 10).unwrap(); - assert!(too_large(l.get_invoice(5).unwrap().balance())); + assert!(too_large(l.ar_aging(d(2026, 6, 1)))); + assert!(too_large(l.invoice_state(4, d(2026, 6, 1)))); // Bills alike. let vendor = l.add_vendor("Supplier").unwrap(); l.replay_bill( @@ -359,7 +385,7 @@ fn documents_and_aging_are_exact_or_refused() { d(2026, 3, 1), d(2026, 3, 31), vec![line(Decimal::MAX), line(Decimal::ONE)], - 6, + 106, ) .unwrap(); assert!(too_large(l.get_bill(1).unwrap().total())); diff --git a/crates/ledger-core/tests/spec_reports.rs b/crates/ledger-core/tests/spec_reports.rs index a1d0417..2bfc05c 100644 --- a/crates/ledger-core/tests/spec_reports.rs +++ b/crates/ledger-core/tests/spec_reports.rs @@ -40,12 +40,18 @@ fn usd(cents: i64) -> Money { } fn post(l: &mut Ledger, date: NaiveDate, desc: &str, lines: &[(AccountId, Side, i64)]) -> EntryId { - let e = l.create_entry(date, desc); - for &(a, s, cents) in lines { - l.add_line(e, a, s, usd(cents)).unwrap(); - } - l.post_entry(e).unwrap(); - e + // As replay posts it: these books hold hand lines on receivables or + // payables (1200, 2000), which books an earlier version wrote still + // hold and every report must read; the control guard (rules.md B-38) + // refuses them as new work. + ledger_core::parties::replaying(|| { + let e = l.create_entry(date, desc); + for &(a, s, cents) in lines { + l.add_line(e, a, s, usd(cents)).unwrap(); + } + l.post_entry(e).unwrap(); + e + }) } fn draft(l: &mut Ledger, date: NaiveDate, lines: &[(AccountId, Side, i64)]) -> EntryId { @@ -1784,22 +1790,28 @@ fn build_ordered(gens: &[Gen], scale: i64, order: &[usize], tag: bool) -> Books let class_a = l.add_class("Alpha").unwrap(); let class_b = l.add_class("Beta").unwrap(); let mut entries = Vec::new(); - for g in gens { - let e = l.create_entry(g.date(), "gen"); - for (n, (i, side, cents)) in g.legs(scale).into_iter().enumerate() { - l.add_line(e, ids[i], side, usd(cents)).unwrap(); - if tag { - let k = match g.classes[n] { - 1 => Some(class_a), - 2 => Some(class_b), - _ => None, - }; - l.set_line_class(e, n, k).unwrap(); + // As replay posts it: these books hold hand lines on receivables or + // payables (1200, 2000), which books an earlier version wrote still + // hold and every report must read; the control guard (rules.md B-38) + // refuses them as new work. + ledger_core::parties::replaying(|| { + for g in gens { + let e = l.create_entry(g.date(), "gen"); + for (n, (i, side, cents)) in g.legs(scale).into_iter().enumerate() { + l.add_line(e, ids[i], side, usd(cents)).unwrap(); + if tag { + let k = match g.classes[n] { + 1 => Some(class_a), + 2 => Some(class_b), + _ => None, + }; + l.set_line_class(e, n, k).unwrap(); + } } + l.post_entry(e).unwrap(); + entries.push(e); } - l.post_entry(e).unwrap(); - entries.push(e); - } + }); Books { l, ids, @@ -2000,12 +2012,15 @@ proptest! { let mut b = build(&gens); let tb0 = tb_lines(&trial_balance_as_of(&b.l, day(as_of))); let pl0 = pnl_key(&profit_and_loss_for_period(&b.l, d(2026, 1, 1), day(as_of), None).unwrap()); - let e = b.l.create_entry(extra.date(), "extra"); - for (i, side, cents) in extra.legs(1) { - b.l.add_line(e, b.ids[i], side, usd(cents)).unwrap(); - } - b.l.post_entry(e).unwrap(); - b.l.reverse_entry(e, extra.date(), "undo").unwrap(); + // As replay posts it (hand lines on 1200 or 2000: see `post`). + ledger_core::parties::replaying(|| { + let e = b.l.create_entry(extra.date(), "extra"); + for (i, side, cents) in extra.legs(1) { + b.l.add_line(e, b.ids[i], side, usd(cents)).unwrap(); + } + b.l.post_entry(e).unwrap(); + b.l.reverse_entry(e, extra.date(), "undo").unwrap(); + }); prop_assert_eq!(tb_lines(&trial_balance_as_of(&b.l, day(as_of))), tb0); let pl1 = profit_and_loss_for_period(&b.l, d(2026, 1, 1), day(as_of), None).unwrap(); prop_assert_eq!(sl_nonzero(&pl1.revenue), pl0.0.into_iter().filter(|x| !x.1.is_zero()).collect::>()); @@ -2027,13 +2042,16 @@ proptest! { let gl = gl_key(&general_ledger(&b.l, s0, e0).unwrap()); let cf = cf_key(&cash_flow(&b.l, &cfp).unwrap()); let snap = snapshot(&b.l).unwrap(); - for g in &drafts { - let id = b.l.create_entry(g.date(), "draft"); - for (n, (i, side, cents)) in g.legs(1).into_iter().enumerate() { - if g.mode == 0 && n == 1 { continue; } // some drafts unbalanced - b.l.add_line(id, b.ids[i], side, usd(cents)).unwrap(); + // As replay adds them (hand lines on 1200 or 2000: see `post`). + ledger_core::parties::replaying(|| { + for g in &drafts { + let id = b.l.create_entry(g.date(), "draft"); + for (n, (i, side, cents)) in g.legs(1).into_iter().enumerate() { + if g.mode == 0 && n == 1 { continue; } // some drafts unbalanced + b.l.add_line(id, b.ids[i], side, usd(cents)).unwrap(); + } } - } + }); prop_assert_eq!(tb_lines(&trial_balance_as_of(&b.l, e0)), tb); prop_assert_eq!(pnl_key(&profit_and_loss_for_period(&b.l, s0, e0, None).unwrap()), pl); prop_assert_eq!(bs_key(&balance_sheet_as_of(&b.l, e0).unwrap()), bs); diff --git a/crates/ledger-core/tests/spec_roles.rs b/crates/ledger-core/tests/spec_roles.rs index a927431..cff37c5 100644 --- a/crates/ledger-core/tests/spec_roles.rs +++ b/crates/ledger-core/tests/spec_roles.rs @@ -99,14 +99,20 @@ fn bal(l: &Ledger, id: AccountId) -> Decimal { l.account_balance(id).expect("known account") } -/// Draft, add lines, post. Panics on any refusal (setup only). +/// Draft, add lines, post. Panics on any refusal (setup only). Posted as +/// replay posts it: these books hold hand lines on receivables or payables +/// (moving a role's balance by hand, B-23's path before documents read +/// their lines), which books an earlier version wrote still hold; the +/// control guard (rules.md B-38) refuses them as new work. fn post(l: &mut Ledger, date: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId { - let id = l.try_create_entry(date, "test entry").unwrap(); - for (a, s, amt) in lines { - l.add_line(id, *a, *s, usd(amt)).unwrap(); - } - l.post_entry(id).unwrap(); - id + ledger_core::parties::replaying(|| { + let id = l.try_create_entry(date, "test entry").unwrap(); + for (a, s, amt) in lines { + l.add_line(id, *a, *s, usd(amt)).unwrap(); + } + l.post_entry(id).unwrap(); + id + }) } /// The (code, side, amount) lines of an entry, sorted. @@ -788,7 +794,7 @@ fn receivables_cannot_move_while_holding_a_balance_but_can_once_moved_out() { let old = acct(&l, "1200").expect("1200 created by the invoice"); let new = add(&mut l, "1100", "Trade Receivables", AccountType::Asset); match refuses(&mut l, "receivables", Some(new)) { - LedgerError::RoleHoldsBalance { + LedgerError::DocumentRoleHoldsBalance { role, code, balance, @@ -798,15 +804,29 @@ fn receivables_cannot_move_while_holding_a_balance_but_can_once_moved_out() { assert_eq!(code, "1200"); assert_eq!(balance, dec("120.00")); } - other => panic!("expected RoleHoldsBalance, got {other:?}"), + other => panic!("expected DocumentRoleHoldsBalance, got {other:?}"), } - // Move the balance with an entry, then the role may move. + // Moving the balance by hand is refused now (the control guard, + // rules.md B-38): until a role's balance moves with its documents + // (plan P4b S7), a receivables role holding a balance stays put. + let e = l.try_create_entry(d(2026, 3, 2), "move").unwrap(); + assert!(matches!( + l.add_line(e, old, Side::Credit, usd("120.00")), + Err(LedgerError::UntaggedControlLine { .. }) + )); + // Books an earlier version wrote can hold such a move, and the + // assignment after it (replayed: today 1100's hand line refuses it, + // RoleAccountHasHandLines): then the documents follow the role. post( &mut l, d(2026, 3, 2), &[(new, Side::Debit, "120.00"), (old, Side::Credit, "120.00")], ); - l.assign_role("receivables", Some(new)).unwrap(); + assert!(matches!( + refuses(&mut l, "receivables", Some(new)), + LedgerError::RoleAccountHasHandLines { .. } + )); + ledger_core::parties::replaying(|| l.assign_role("receivables", Some(new))).unwrap(); assert_eq!(l.role_account(&RECEIVABLES), Some(new)); // The open invoice is now controlled by 1100: its payment credits it. let p = l @@ -845,6 +865,21 @@ fn every_balance_sheet_role_holds_while_its_account_has_a_balance() { &format!("Target {i}"), c.account_type, ); + if c.key == RECEIVABLES.key || c.key == PAYABLES.key { + // No document posted there: the balance is the chart's own, + // so the role moves (B-38); it can't come back to an account + // holding hand lines. + l.assign_role(c.key, Some(target)).unwrap(); + assert!( + matches!( + refuses(&mut l, c.key, Some(*cur)), + LedgerError::RoleAccountHasHandLines { .. } + ), + "{}", + c.key + ); + continue; + } assert!( matches!( refuses(&mut l, c.key, Some(target)), @@ -877,7 +912,7 @@ fn none_is_refused_while_the_assigned_balance_sheet_account_holds_a_balance() { .unwrap(); assert!(matches!( refuses(&mut l, "payables", None), - LedgerError::RoleHoldsBalance { .. } + LedgerError::DocumentRoleHoldsBalance { .. } )); let _ = cash; } @@ -1121,7 +1156,7 @@ fn pool_books() -> (Ledger, Vec) { } /// The reasons the spec gives to refuse an assignment, worked from the -/// current books (assign_role SCENARIOS 1–5). +/// current books (assign_role SCENARIOS 1–6). fn predicted_refusals( l: &Ledger, c: &ControlAccount, @@ -1142,10 +1177,27 @@ fn predicted_refusals( } let now = l.role_account(c); let next = target.map(|a| a.id); + // SCENARIO 6: a document role never moves onto hand lines. + let documents = c.key == RECEIVABLES.key || c.key == PAYABLES.key; + if documents + && let Some(a) = target + && next != now + && l.posted_entries().any(|e| { + e.lines() + .iter() + .enumerate() + .any(|(i, x)| x.account == a.id && l.line_doc(e.id(), i).is_none()) + }) + { + why.push("handlines"); + } + // SCENARIO 4: a document role's balance holds only on its control + // account (assigned there, or holding its documents' lines). if balance_sheet(c) && next != now && let Some(cur) = now && !bal(l, cur).is_zero() + && (!documents || l.control_role_of(cur).is_some_and(|r| r.key == c.key)) { why.push("balance"); } @@ -1156,7 +1208,10 @@ fn reason(e: &LedgerError) -> &'static str { match e { LedgerError::ControlAccountMismatch { .. } => "mismatch", LedgerError::RoleTaken { .. } => "taken", - LedgerError::RoleHoldsBalance { .. } => "balance", + LedgerError::RoleHoldsBalance { .. } | LedgerError::DocumentRoleHoldsBalance { .. } => { + "balance" + } + LedgerError::RoleAccountHasHandLines { .. } => "handlines", _ => "other", } } diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index 53358ec..8ce3890 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -9,9 +9,9 @@ //! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital | //! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue | //! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash | -//! | Feb 3 | software 120 on the card | 6000 Software | 2000 Card | +//! | Feb 3 | software 120 on the card | 6000 Software | 2050 Card | //! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue | -//! | Feb 25 | pay the card 120 | 2000 Card | 1000 Cash | +//! | Feb 25 | pay the card 120 | 2050 Card | 1000 Cash | use chrono::NaiveDate; use ledger_core::reports::{ @@ -57,7 +57,7 @@ fn books(close_january: bool) -> Books { .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let card = l - .add_account("2000", "Card", AccountType::Liability, None, false) + .add_account("2050", "Card", AccountType::Liability, None, false) .unwrap(); let capital = l .add_account("3000", "Capital", AccountType::Equity, None, false) @@ -160,7 +160,7 @@ fn balance_sheet_is_as_of_the_date() { // Feb 15: cash 12,700; the card owes 120; earnings 1,700 + 880. let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap(); assert_eq!(mid.total_assets, dec(12_700)); - assert_eq!(mid.liabilities, vec![line("2000", "Card", 120)]); + assert_eq!(mid.liabilities, vec![line("2050", "Card", 120)]); assert_eq!(mid.current_earnings, dec(2_580)); assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700)); @@ -307,7 +307,7 @@ fn general_ledger_runs_balances_by_account() { let gl = general_ledger(&b.ledger, d(2, 1), d(2, 28)).unwrap(); let codes: Vec<&str> = gl.accounts.iter().map(|a| a.code.as_str()).collect(); // Every account with a beginning balance or February activity, by code. - assert_eq!(codes, ["1000", "2000", "3000", "4000", "6000", "6100"]); + assert_eq!(codes, ["1000", "2050", "3000", "4000", "6000", "6100"]); let cash = &gl.accounts[0]; assert_eq!(cash.beginning_balance, dec(11_700)); @@ -320,7 +320,7 @@ fn general_ledger_runs_balances_by_account() { p, [ (dec(1_000), dec(0), "4000 Revenue", dec(12_700)), - (dec(0), dec(120), "2000 Card", dec(12_580)), + (dec(0), dec(120), "2050 Card", dec(12_580)), ] ); assert_eq!(cash.ending_balance, dec(12_580)); @@ -354,7 +354,7 @@ fn general_ledger_marks_compound_entries_as_split() { .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let card = l - .add_account("2000", "Card", AccountType::Liability, None, false) + .add_account("2050", "Card", AccountType::Liability, None, false) .unwrap(); let soft = l .add_account("6000", "Software", AccountType::Expense, None, false) @@ -373,7 +373,7 @@ fn general_ledger_marks_compound_entries_as_split() { // The debit side has two other accounts; each credit has one. assert_eq!(split_of("6000"), ("-Split-".to_string(), e)); assert_eq!(split_of("1000"), ("6000 Software".to_string(), e)); - assert_eq!(split_of("2000"), ("6000 Software".to_string(), e)); + assert_eq!(split_of("2050"), ("6000 Software".to_string(), e)); } /// Two members, 50/50: Alice and Bob. January: Alice puts in 600, Bob 400; @@ -1087,7 +1087,7 @@ fn cash_flow_labels_say_which_way_each_balance_moved() { .add_account("1100", "Receivable", AccountType::Asset, None, false) .unwrap(); let ap = l - .add_account("2000", "Payable", AccountType::Liability, None, false) + .add_account("2010", "Payable", AccountType::Liability, None, false) .unwrap(); let loan = l .add_account("2500", "Loan", AccountType::Liability, None, false) @@ -1144,7 +1144,7 @@ fn cash_flow_labels_say_which_way_each_balance_moved() { rec(&feb), [ (s("Increase in 1100 Receivable"), dec(-100)), - (s("Increase in 2000 Payable"), dec(50)), + (s("Increase in 2010 Payable"), dec(50)), ] ); assert_eq!( @@ -1167,7 +1167,7 @@ fn cash_flow_labels_say_which_way_each_balance_moved() { rec(&mar), [ (s("Decrease in 1100 Receivable"), dec(100)), - (s("Decrease in 2000 Payable"), dec(-50)), + (s("Decrease in 2010 Payable"), dec(-50)), ] ); assert_eq!( diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 1384b76..047e39b 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -179,9 +179,10 @@ pub fn period_month(period: &str) -> Option<(i32, u32)> { /// 3. invoiced_ytd = Σ Invoice::total() over its customer record's /// invoices dated in the year; billed_ytd the same for its vendor /// record's bills. -/// 4. open_invoices = Σ Invoice::balance() over its customer record's -/// invoices; open_bills = Σ Bill::balance() over its vendor record's -/// bills (any date: what is still owed today). +/// 4. open_invoices = Σ what each of its customer record's invoices +/// owes today (Books::owed_today: read from the ledger lines tagged +/// with it, so a reversed payment or a void counts as it should, +/// rules.md B-38); open_bills the same for its vendor record's bills. /// 4a. Each of 3 and 4 is None when it can't be summed exactly (a /// document is restored as an earlier version journaled it, of any /// size: rules.md B-28), never a wrong number. @@ -1138,7 +1139,10 @@ impl Books { .and_then(|p| out.get_mut(&p)) { info.invoices.push(id); - add(&mut info.open_invoices, inv.balance()); + add( + &mut info.open_invoices, + self.owed_today(ledger_core::DocRef::Invoice(id)), + ); if in_year(inv.date) { add(&mut info.invoiced_ytd, inv.total()); } @@ -1151,7 +1155,10 @@ impl Books { .and_then(|p| out.get_mut(&p)) { info.bills.push(id); - add(&mut info.open_bills, bill.balance()); + add( + &mut info.open_bills, + self.owed_today(ledger_core::DocRef::Bill(id)), + ); if in_year(bill.date) { add(&mut info.billed_ytd, bill.total()); } @@ -1309,6 +1316,38 @@ impl Books { .collect() } + /// A document as of today: what it is, read from the ledger lines + /// tagged with it (ledger_core::Ledger::doc_state, rules.md B-38), the + /// figures books.ar_aging and books.party read. One dated after today + /// (a post-dated bill) is read as of its own date: what it will owe + /// when it is issued, so the forward-looking views keep it; it is + /// never late. `Ok(None)` only for an unknown document. + pub fn doc_today( + &self, + doc: ledger_core::DocRef, + ) -> Result, ledger_core::LedgerError> { + let date = match doc { + ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.date), + ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.date), + }; + let Some(date) = date else { + return Ok(None); + }; + self.ledger.doc_state(doc, self.today.max(date)) + } + + /// What a document owes today ([`Self::doc_today`]: one dated after + /// today, what it will owe on its date); refused when it can't be + /// computed (shown as such, never as a wrong number). + pub fn owed_today( + &self, + doc: ledger_core::DocRef, + ) -> Result { + Ok(self + .doc_today(doc)? + .map_or(Decimal::ZERO, |st| st.outstanding)) + } + /// The subjects whose documents an entry's page shows: the entry, and /// the invoice, bill, or payroll run it records (those have no page /// of their own). @@ -2193,6 +2232,18 @@ fn disposal_true_up( .map_or(Decimal::ZERO, |t| t - runs) } +/// What a document's state ([`Books::doc_today`]) says it owes, read once +/// for both the row and its flags: `None` when it can't be computed +/// (shown as such, never as a wrong number); nothing for an unknown one. +pub fn owed_of( + state: &Result, ledger_core::LedgerError>, +) -> Option { + match state { + Ok(st) => Some(st.map_or(Decimal::ZERO, |st| st.outstanding)), + Err(_) => None, + } +} + /// Entry ids a journal line refers to. pub fn entry_refs(line: &JournalLine) -> Vec { let p = &line.payload; diff --git a/crates/ledger-gui/src/pages/cash.rs b/crates/ledger-gui/src/pages/cash.rs index 3351fb9..43f4278 100644 --- a/crates/ledger-gui/src/pages/cash.rs +++ b/crates/ledger-gui/src/pages/cash.rs @@ -328,7 +328,10 @@ fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let Some(inv) = b.ledger.get_invoice(id) else { continue; }; - let balance = inv.balance().ok(); + // As of today, from the lines tagged with it (rules.md B-38): a + // reversed payment reopens it, a void closes it. + let state = b.doc_today(ledger_core::DocRef::Invoice(id)); + let balance = crate::books::owed_of(&state); if balance.is_some_and(|x| x.is_zero()) { continue; } @@ -337,7 +340,7 @@ fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { .get_customer(inv.customer_id) .map(|c| c.name.clone()) .unwrap_or_default(); - let late = inv.due_date < b.today; + let late = matches!(state, Ok(Some(st)) if st.overdue_days > 0); items.push(Upcoming { date: inv.due_date, label: format!( @@ -352,7 +355,7 @@ fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let Some(bill) = b.ledger.get_bill(id) else { continue; }; - let balance = bill.balance().ok(); + let balance = crate::books::owed_of(&b.doc_today(ledger_core::DocRef::Bill(id))); if balance.is_some_and(|x| x.is_zero()) { continue; } @@ -645,7 +648,7 @@ mod tests { let mut b = Build::new("coming-up"); let (cash, capital, rent) = (b.cash, b.capital, b.rent); let accrued = - b.s.add_account("2000", "Accrued", AccountType::Liability, None, false) + b.s.add_account("2100", "Accrued", AccountType::Liability, None, false) .unwrap(); b.post(d(2026, 1, 2), cash, capital, 1_000_000); let usd = |c: i64| Money::new(cents(c), Currency::USD); diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index bc16196..9430677 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -714,7 +714,11 @@ fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let owed = (1..b.ledger.next_invoice_id()) .filter_map(|i| b.ledger.get_invoice(i)) .filter(|i| i.customer_id == c.id) - .try_fold(Decimal::ZERO, |t, i| t.checked_add(i.balance().ok()?)); + .try_fold(Decimal::ZERO, |t, i| { + t.checked_add( + b.owed_today(ledger_core::DocRef::Invoice(i.id)).ok()?, + ) + }); ui.horizontal_wrapped(|ui| { name(ui, &c.name, b.ledger.party_of_customer(c.id)); ui.with_layout( diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index d993704..641970e 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -1400,13 +1400,15 @@ fn documents_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option = invoices.chain(bills).collect(); docs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); - for (name, date, due, status, total, balance, entry) in docs { - let late = !balance.is_some_and(|x| x.is_zero()) && due < b.today; + for (name, date, due, state, total, balance, entry) in docs { + // Late and its status as of today, from the ledger + // lines tagged with it (rules.md B-38; P4b.2 gap 10): + // a reversed payment makes it owed again, a void + // never late. + let late = matches!(state, Ok(Some(st)) if st.overdue_days > 0); + let status = match state { + // Read as of its own date (Books::doc_today). + Ok(Some(_)) if date > b.today => "Dated ahead", + Ok(Some(st)) => st.status.label(), + Ok(None) => "Unknown", + Err(_) => "Can't compute", + }; let resp = row( ui, w, @@ -1441,7 +1456,7 @@ fn documents_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option 0, "{late:?}"); + let party = b + .ledger + .party_of_vendor(vendor) + .and_then(|p| b.party(p).cloned()) + .unwrap(); + assert_eq!(party.open_bills, Some(cents(51_000))); + assert_eq!( + b.owed_today(ledger_core::DocRef::Bill(later)), + Ok(cents(45_000)) + ); +} diff --git a/crates/ledger-gui/tests/projects.rs b/crates/ledger-gui/tests/projects.rs index 177df28..7dc9d9e 100644 --- a/crates/ledger-gui/tests/projects.rs +++ b/crates/ledger-gui/tests/projects.rs @@ -67,7 +67,7 @@ fn fixture(tag: &str) -> Fixture { let mut s = Store::create(&data_dir, Currency::USD).unwrap(); let mut add = |code: &str, name: &str, ty| s.add_account(code, name, ty, None, false).unwrap(); let cash = add("1000", "Cash", AccountType::Asset); - let devices = add("1200", "Equipment", AccountType::Asset); + let devices = add("1500", "Equipment", AccountType::Asset); let revenue = add("4000", "Design Services", AccountType::Revenue); let contractors = add("6000", "Contractors", AccountType::Expense); let rent = add("6200", "Rent", AccountType::Expense); diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index 3ff5cae..cf3c1e7 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -82,13 +82,19 @@ fn build(plan: &Plan, tag: &str) -> (TestDir, Source, ledger_core::Ledger) { .iter() .map(|(c, n, t)| s.add_account(c, n, *t, None, false).unwrap()) .collect(); - for &(back, dr, cr, cents) in &plan.entries { - let e = s.create_entry(plan.today - Days::new(back), "x").unwrap(); - let m = Money::new(Decimal::new(cents, 2), Currency::USD); - s.add_line(e, ids[dr], Side::Debit, m).unwrap(); - s.add_line(e, ids[cr], Side::Credit, m).unwrap(); - s.post_entry(e).unwrap(); - } + // As replay posts them: random lines land on receivables and payables + // by hand, as books an earlier version wrote can hold them (the + // control guard, rules.md B-38, refuses them as new work), and the + // overview must still read them. + ledger_core::parties::replaying(|| { + for &(back, dr, cr, cents) in &plan.entries { + let e = s.create_entry(plan.today - Days::new(back), "x").unwrap(); + let m = Money::new(Decimal::new(cents, 2), Currency::USD); + s.add_line(e, ids[dr], Side::Debit, m).unwrap(); + s.add_line(e, ids[cr], Side::Credit, m).unwrap(); + s.post_entry(e).unwrap(); + } + }); journal_settings( &mut s, &format!( diff --git a/crates/ledger-import/tests/spec_import.rs b/crates/ledger-import/tests/spec_import.rs index fd0261c..6d28643 100644 --- a/crates/ledger-import/tests/spec_import.rs +++ b/crates/ledger-import/tests/spec_import.rs @@ -67,7 +67,7 @@ const CHART: &[(&str, &str, AccountType, Option<&str>)] = &[ ("1010", "Reserve: Savings", AccountType::Asset, Some("1000")), ("1100", "Receivables", AccountType::Asset, None), ("1500", "Equipment", AccountType::Asset, None), - ("2000", "Card", AccountType::Liability, None), + ("2050", "Card", AccountType::Liability, None), ("2100", "Loan", AccountType::Liability, None), ("3000", "Capital — Ada", AccountType::Equity, None), ("3001", "Capital — Grace", AccountType::Equity, None), @@ -1098,7 +1098,7 @@ fn qb(a: &ledger_core::Account) -> QbAccountType { AccountType::Asset if a.code.starts_with("10") => QbAccountType::Bank, AccountType::Asset if a.code == "1500" => QbAccountType::FixedAsset, AccountType::Asset => QbAccountType::OtherCurrentAsset, - AccountType::Liability if a.code == "2000" => QbAccountType::CreditCard, + AccountType::Liability if a.code == "2050" => QbAccountType::CreditCard, AccountType::Liability => QbAccountType::OtherCurrentLiability, AccountType::Equity => QbAccountType::Equity, AccountType::Revenue => QbAccountType::Income, diff --git a/crates/ledger-package/tests/package.proptest-regressions b/crates/ledger-package/tests/package.proptest-regressions new file mode 100644 index 0000000..e635fd5 --- /dev/null +++ b/crates/ledger-package/tests/package.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc ce36b6206d356acbdc152d5cfbfb472816f4fdbaccf1bb0e15cc76cb7972b23b # shrinks to entries = [(0, 2, 0, 1)], reversals = [], closed = 0, as_of = 0 diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index d7760b8..972b90c 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -1920,33 +1920,39 @@ fn the_readme_names_the_entries_on_receivables_and_payables() { ] { s.add_account(code, name, ty, None, false).unwrap(); } - let invoiced = post( - &mut s, - "2026-04-01", - "Invoice 7", - &[ - ("1200", Side::Debit, "500.00"), - ("4000", Side::Credit, "500.00"), - ], - ); - let billed = post( - &mut s, - "2026-04-02", - "Bill 3", - &[ - ("6000", Side::Debit, "80.00"), - ("2000", Side::Credit, "80.00"), - ], - ); - let netted = post( - &mut s, - "2026-04-03", - "Netted", - &[ - ("2000", Side::Debit, "80.00"), - ("1200", Side::Credit, "80.00"), - ], - ); + // Hand lines on receivables and payables, as books an earlier version + // wrote hold them (the control guard, rules.md B-38, refuses them as + // new work): posted as replay posts them. + let (invoiced, billed, netted) = ledger_core::parties::replaying(|| { + let invoiced = post( + &mut s, + "2026-04-01", + "Invoice 7", + &[ + ("1200", Side::Debit, "500.00"), + ("4000", Side::Credit, "500.00"), + ], + ); + let billed = post( + &mut s, + "2026-04-02", + "Bill 3", + &[ + ("6000", Side::Debit, "80.00"), + ("2000", Side::Credit, "80.00"), + ], + ); + let netted = post( + &mut s, + "2026-04-03", + "Netted", + &[ + ("2000", Side::Debit, "80.00"), + ("1200", Side::Credit, "80.00"), + ], + ); + (invoiced, billed, netted) + }); let company = company(false); let cash = cash(); let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap(); @@ -1985,15 +1991,17 @@ fn the_readme_names_the_entries_on_receivables_and_payables() { false, ) .unwrap(); - let one = post( - &mut s, - "2026-04-01", - "Owed", - &[ - ("1200", Side::Debit, "5.00"), - ("1000", Side::Credit, "5.00"), - ], - ); + let one = ledger_core::parties::replaying(|| { + post( + &mut s, + "2026-04-01", + "Owed", + &[ + ("1200", Side::Debit, "5.00"), + ("1000", Side::Credit, "5.00"), + ], + ) + }); let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap(); assert!(text(&p, "README.txt").contains(&format!( "lines to accounts receivable or payable, in entry {one}: " @@ -2037,7 +2045,7 @@ fn random_ledger( for (code, name, ty) in [ ("1000", "Checking", AccountType::Asset), ("1100", "Receivables", AccountType::Asset), - ("2000", "Card", AccountType::Liability), + ("2050", "Card", AccountType::Liability), ("3000", "Capital", AccountType::Equity), ("4000", "Sales", AccountType::Revenue), ("6000", "Rent", AccountType::Expense), diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index c39ccfc..acec73f 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -61,6 +61,7 @@ pub const KNOWN_FEATURES: &[&str] = &[ DEPRECIATION_CATCH_UP, PROJECTS, DOCUMENT_LINE_PROJECTS, + DOCUMENT_STANDING, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -163,6 +164,16 @@ pub const PROJECTS: &str = "projects"; /// consent again before a document names one. pub const DOCUMENT_LINE_PROJECTS: &str = "document_line_projects"; +/// Feature: an `invoice.payment_received` or `bill.paid` accepted only +/// because an earlier payment on its document was reversed (plan P4b S1, +/// D-45; rules.md B-38): this version reads a document's figures from the +/// ledger lines that stand, so a reversed payment no longer counts and the +/// right one is taken. An older version adds every payment event to the +/// document's paid amount, standing or not: it would replay the new +/// payment on top of the reversed one, paid above the total, silently. +/// Every other payment is written as before and needs no feature. +pub const DOCUMENT_STANDING: &str = "document_standing"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -788,6 +799,41 @@ mod tests { ); } + /// The version before P4b S1 (every feature through + /// `document_line_projects`) stops at a declaration of + /// `document_standing` as newer, never adding a payment taken after a + /// reversal on top of the reversed one; and books that declared every + /// earlier feature still wait for it (B-27, D-39). + #[test] + fn document_standing_is_its_own_feature() { + assert!(KNOWN_FEATURES.contains(&DOCUMENT_STANDING)); + let before: Vec<&str> = KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != DOCUMENT_STANDING) + .collect(); + let l = line( + FORMAT_SET, + json!({"format": 2, "features": [DOCUMENT_LINE_PROJECTS, DOCUMENT_STANDING]}), + ); + match declared_knowing(&l, &before) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, "journal features document_standing") + } + other => panic!("expected NewerJournal, got {other:?}"), + } + let older = JournalFormat { + format: 2, + features: before.iter().map(|f| f.to_string()).collect(), + }; + assert!(matches!( + older.require_feature(DOCUMENT_STANDING), + Err(StoreError::FormatUpgradePending { .. }) + )); + let up = older.upgrade().expect("waits for it"); + assert_eq!(up.adds(), vec!["the journal feature document_standing"]); + } + /// This version's format: the newest it reads, every feature it /// knows, each once, sorted (what new books and an upgrade declare). #[test] diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index eee0668..2c34633 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 14; +pub const SCHEMA_VERSION: u32 = 15; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -385,5 +385,16 @@ INSERT INTO asset_depreciation_v13(asset_id, period, entry_id, amount) SELECT asset_id, period, entry_id, amount FROM asset_depreciation; DROP TABLE asset_depreciation; ALTER TABLE asset_depreciation_v13 RENAME TO asset_depreciation; +"#, + r#" +-- v15: a document keeps no figure of its own (plan P4b S1, D-45; rules.md +-- B-38): what an invoice or bill has been paid, and its status, are read +-- from the ledger lines tagged with it, as of any date, and a reversed +-- payment no longer counts. The columns that summed its payment events +-- (and so went on counting a reversed one) go. +ALTER TABLE invoices DROP COLUMN amount_paid; +ALTER TABLE invoices DROP COLUMN status; +ALTER TABLE bills DROP COLUMN amount_paid; +ALTER TABLE bills DROP COLUMN status; "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index c0bd0a2..c5410b0 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -3079,9 +3079,9 @@ impl Store { Ok(live) } - /// Record a customer payment against an invoice. The payment entry (Dr - /// cash, Cr A/R) is journaled through the standard entry flow first, - /// then `invoice.payment_received`. + /// Record a customer payment against an invoice + /// ([`Self::receive_payment_with`], with no payment method or + /// reference). pub fn receive_payment( &mut self, invoice_id: InvoiceId, @@ -3089,15 +3089,56 @@ impl Store { amount: Decimal, cash_account: AccountId, ) -> Result { - self.rehearse(|l| l.receive_payment(invoice_id, date, amount, cash_account))?; + self.receive_payment_with( + invoice_id, + date, + amount, + cash_account, + &ledger_core::PaymentDetails::default(), + ) + } + + /// Record a customer payment against an invoice, with its payment + /// method and reference ([`Ledger::receive_payment_with`]). The + /// payment entry (Dr the deposit account, Cr A/R) is journaled through + /// the standard entry flow first (the reference in its description), + /// then `invoice.payment_received`, then the payment method as + /// `entry.instrument_set` (feature `payment_instruments`). + /// + /// ```text + /// GOAL: a payment is journaled once and replays to the same figures, + /// in any version that can open the books. + /// SOURCE: docs/spec/rules.md B-27, B-38; plan/served.md P4b.7 + /// (document_standing). + /// SCENARIOS: + /// 1. IF the engine refuses it (overpaid, before the invoice, a + /// deposit account that isn't cash): SHOULD refuse, journaling + /// nothing. + /// 2. IF it is accepted only because a payment on the invoice was + /// reversed (an older version, which counts every payment event, + /// would see it paid above its total): SHOULD need the journal + /// feature document_standing, refused (FormatUpgradePending) in + /// books that haven't declared it, journaling nothing. + /// 3. IF it names a payment method: SHOULD need payment_instruments. + /// ``` + pub fn receive_payment_with( + &mut self, + invoice_id: InvoiceId, + date: NaiveDate, + amount: Decimal, + cash_account: AccountId, + details: &ledger_core::PaymentDetails, + ) -> Result { + self.rehearse(|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details))?; self.approval_gate( - |l| l.receive_payment(invoice_id, date, amount, cash_account), + |l| l.receive_payment_with(invoice_id, date, amount, cash_account, details), false, )?; // The rehearsal above is the whole check: hand-written mirrors of // the engine's refusals drift from it (and were never reached). + self.ensure_payment_features(ledger_core::DocRef::Invoice(invoice_id), amount, details)?; let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; - let description = format!("Payment on invoice {invoice_id}"); + let description = details.description(&format!("Payment on invoice {invoice_id}")); let (entry_id, mut journaled) = self.journal_auto_entry(date, &description, cash_account, ar, amount)?; journaled.push(self.append_journal( @@ -3110,9 +3151,15 @@ impl Store { "entry_id": entry_id, }), )?); - let live = self - .ledger - .receive_payment(invoice_id, date, amount, cash_account)?; + if details.instrument.is_some() { + journaled.push(self.append_journal( + "entry.instrument_set", + crate::form1099::entry_instrument_set(entry_id, details.instrument), + )?); + } + let live = + self.ledger + .receive_payment_with(invoice_id, date, amount, cash_account, details)?; debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); for line in &journaled { apply_line_to_sqlite(&self.conn, line)?; @@ -3120,8 +3167,8 @@ impl Store { Ok(live) } - /// Pay a vendor bill. The payment entry (Dr A/P, Cr cash) is journaled - /// through the standard entry flow first, then `bill.paid`. + /// Pay a vendor bill ([`Self::pay_bill_with`], with no payment method + /// or reference). pub fn pay_bill( &mut self, bill_id: BillId, @@ -3129,10 +3176,36 @@ impl Store { amount: Decimal, cash_account: AccountId, ) -> Result { - self.rehearse(|l| l.pay_bill(bill_id, date, amount, cash_account))?; - self.approval_gate(|l| l.pay_bill(bill_id, date, amount, cash_account), false)?; + self.pay_bill_with( + bill_id, + date, + amount, + cash_account, + &ledger_core::PaymentDetails::default(), + ) + } + + /// Pay a vendor bill, with its payment method and reference + /// ([`Ledger::pay_bill_with`]). The payment entry (Dr A/P, Cr the + /// payer account) is journaled through the standard entry flow first, + /// then `bill.paid`, then the payment method as `entry.instrument_set`; + /// as [`Self::receive_payment_with`] (its SCENARIOS too). + pub fn pay_bill_with( + &mut self, + bill_id: BillId, + date: NaiveDate, + amount: Decimal, + cash_account: AccountId, + details: &ledger_core::PaymentDetails, + ) -> Result { + self.rehearse(|l| l.pay_bill_with(bill_id, date, amount, cash_account, details))?; + self.approval_gate( + |l| l.pay_bill_with(bill_id, date, amount, cash_account, details), + false, + )?; + self.ensure_payment_features(ledger_core::DocRef::Bill(bill_id), amount, details)?; let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; - let description = format!("Payment on bill {bill_id}"); + let description = details.description(&format!("Payment on bill {bill_id}")); let (entry_id, mut journaled) = self.journal_auto_entry(date, &description, ap, cash_account, amount)?; journaled.push(self.append_journal( @@ -3145,7 +3218,15 @@ impl Store { "entry_id": entry_id, }), )?); - let live = self.ledger.pay_bill(bill_id, date, amount, cash_account)?; + if details.instrument.is_some() { + journaled.push(self.append_journal( + "entry.instrument_set", + crate::form1099::entry_instrument_set(entry_id, details.instrument), + )?); + } + let live = self + .ledger + .pay_bill_with(bill_id, date, amount, cash_account, details)?; debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); for line in &journaled { apply_line_to_sqlite(&self.conn, line)?; @@ -3153,6 +3234,51 @@ impl Store { Ok(live) } + /// The journal features a payment of `amount` on `doc` needs + /// ([`Self::receive_payment_with`] SCENARIOS 2 and 3): document_standing + /// when more than an older version thinks is still owed (the + /// document's total less every payment event it recorded, reversed + /// or not, its line tagged or not: `Ledger::payments_recorded`), + /// payment_instruments when it names a payment method. + fn ensure_payment_features( + &self, + doc: ledger_core::DocRef, + amount: Decimal, + details: &ledger_core::PaymentDetails, + ) -> Result<(), StoreError> { + let total = match doc { + ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()), + ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()), + }; + if let Some(total) = total { + let older_owed = total?.checked_sub(self.ledger.payments_recorded(doc)?); + if older_owed.is_none_or(|owed| amount > owed) { + self.ensure_feature(crate::format::DOCUMENT_STANDING)?; + } + } + if details.instrument.is_some() { + self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?; + } + Ok(()) + } + + /// Void an invoice or bill: reverse its own entry, described "Void + /// invoice 7: reason" ([`Ledger::void_document`]), journaled as the + /// reversal it is (`entry.reversed`: a void writes no event of its + /// own, plan P4b.7). Refused, journaling nothing, as + /// [`Ledger::check_void_document`] and [`Self::check_reverse_entry`] + /// refuse it (a payment standing on it, void already, a date before + /// it). Returns the reversal's entry. + pub fn void_document( + &mut self, + doc: ledger_core::DocRef, + date: NaiveDate, + reason: &str, + ) -> Result { + let (target, description) = self.ledger.check_void_document(doc, date, reason)?; + self.reverse_entry(target, date, &description) + } + // ------------------------------------------------------------------ // Foreign exchange // ------------------------------------------------------------------ @@ -5137,7 +5263,7 @@ impl Store { Some(orig) => { // Reversals allocate the next id; replayed in id order the // allocator is in sync, so the id must match the row. - let got = ledger.reverse_entry(orig as u64, date, &description)?; + let got = ledger.replay_reverse_entry(orig as u64, date, &description)?; if got != id as u64 { return Err(StoreError::ReplayMismatch(format!( "reversal id: sqlite says {id}, replay got {got}" @@ -5809,7 +5935,9 @@ fn replay_line( "entry.reversed" => { let want = get_u64(p, "id")?; let orig = get_u64(p, "reverses")?; - let got = ledger.reverse_entry( + // As journaled: a reversal later rules would refuse (a void + // under a payment that stands) replays (rules.md B-28). + let got = ledger.replay_reverse_entry( orig, parse_date(get_str(p, "date")?)?, get_str(p, "description")?, @@ -6385,54 +6513,11 @@ fn replay_line( Ok(()) } -/// Apply a payment to an invoice or bill row in the SQLite projection: -/// bump `amount_paid` and re-derive the status. Amounts are TEXT in -/// SQLite, so the math happens here in Rust with exact Decimals. -/// `table` is `invoices` or `bills`; an internal constant, never user input. -fn apply_document_payment( - conn: &Connection, - table: &str, - id: u64, - amount: Decimal, -) -> Result<(), StoreError> { - let (paid_str, lines_json): (String, String) = conn.query_row( - &format!("SELECT amount_paid, lines_json FROM {table} WHERE id = ?"), - params![id as i64], - |r| Ok((r.get(0)?, r.get(1)?)), - )?; - let paid: Decimal = paid_str - .parse() - .map_err(|_| StoreError::JournalCorrupt(format!("bad amount_paid {paid_str}")))?; - let too_large = || StoreError::JournalCorrupt(format!("{table} {id}: amounts too large")); - let new_paid = paid.checked_add(amount).ok_or_else(too_large)?; - let lines: Vec = serde_json::from_str(&lines_json) - .map_err(|_| StoreError::JournalCorrupt("bad lines_json".into()))?; - let mut total = Decimal::ZERO; - for l in &lines { - let amount = get_str(l, "amount")? - .parse::() - .map_err(|_| StoreError::JournalCorrupt("bad line amount".into()))?; - total = total.checked_add(amount).ok_or_else(too_large)?; - } - let status = if new_paid.is_zero() { - "open" - } else if new_paid >= total { - "paid" - } else { - "partial" - }; - conn.execute( - &format!("UPDATE {table} SET amount_paid = ?, status = ? WHERE id = ?"), - params![new_paid.to_string(), status, id as i64], - )?; - Ok(()) -} - /// Apply one journal line to the SQLite projection: INSERTs plus the events /// mirror, then advance `last_applied_seq`. The applier is a pure function /// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs /// for lifecycle changes (receipt linking, disposal, period close, -/// cleared flags, document payments, fx rate updates). +/// cleared flags, fx rate updates). /// The close target recorded in a `period.closed` line. Absent or /// `"retained_earnings"`: Retained Earnings with no sweeps (every journal /// written before targets existed). @@ -7219,15 +7304,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr StoreError::JournalCorrupt("invoice.created without lines".into()) })?; conn.execute( - "INSERT INTO invoices(id, customer_id, date, due_date, lines_json, amount_paid, status, entry_id) VALUES (?,?,?,?,?,?,?,?)", + "INSERT INTO invoices(id, customer_id, date, due_date, lines_json, entry_id) VALUES (?,?,?,?,?,?)", params![ get_u64(p, "id")? as i64, get_u64(p, "customer_id")? as i64, get_str(p, "date")?, get_str(p, "due_date")?, lines.to_string(), - "0", - "open", get_u64(p, "entry_id")? as i64, ], )?; @@ -7237,15 +7320,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr .get("lines") .ok_or_else(|| StoreError::JournalCorrupt("bill.created without lines".into()))?; conn.execute( - "INSERT INTO bills(id, vendor_id, date, due_date, lines_json, amount_paid, status, entry_id) VALUES (?,?,?,?,?,?,?,?)", + "INSERT INTO bills(id, vendor_id, date, due_date, lines_json, entry_id) VALUES (?,?,?,?,?,?)", params![ get_u64(p, "id")? as i64, get_u64(p, "vendor_id")? as i64, get_str(p, "date")?, get_str(p, "due_date")?, lines.to_string(), - "0", - "open", get_u64(p, "entry_id")? as i64, ], )?; @@ -7257,17 +7338,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr ], )?; } - "invoice.payment_received" => { - let amount: Decimal = get_str(p, "amount")? - .parse() - .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?; - apply_document_payment(conn, "invoices", get_u64(p, "invoice_id")?, amount)?; - } + // A payment moves no document row: what a document is paid is read + // from the ledger lines tagged with it (schema v15, rules.md B-38). + "invoice.payment_received" => {} "bill.paid" => { - let amount: Decimal = get_str(p, "amount")? - .parse() - .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?; - apply_document_payment(conn, "bills", get_u64(p, "bill_id")?, amount)?; conn.execute( "INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \ SELECT ?, vendor_id FROM bills WHERE id = ?", diff --git a/crates/ledger-store/tests/arap_props.proptest-regressions b/crates/ledger-store/tests/arap_props.proptest-regressions new file mode 100644 index 0000000..788a3a7 --- /dev/null +++ b/crates/ledger-store/tests/arap_props.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 704ab49c9bea84359475c3afa56c3354275a353670fc71156fba151f846f4f73 # shrinks to first = [Create(false, 0, 1), Create(false, 0, 1)], then = [Close(1), Hand(false, 1)] diff --git a/crates/ledger-store/tests/arap_props.rs b/crates/ledger-store/tests/arap_props.rs new file mode 100644 index 0000000..c806128 --- /dev/null +++ b/crates/ledger-store/tests/arap_props.rs @@ -0,0 +1,302 @@ +//! Documents through the store, at random (rules.md B-38 INVARIANTS 1, 2, +//! 4 and 6; plan P4b.15): histories of invoices, bills, payments (with a +//! payment method and a reference, or none), voids, reversals of any +//! entry, closed months and refused hand lines, run through `Store`, then +//! replayed (the books reopened) and read by a `Snapshot`. +//! +//! ```text +//! GOAL: a document's state is a function of the journal and the day: +//! the live books, their replay and a viewer's snapshot agree on +//! every figure, at every date, and the control accounts equal +//! their documents. +//! SOURCE: docs/spec/rules.md B-28 (replay), B-38 INVARIANTS 1, 2, 4, 6. +//! ``` + +use chrono::NaiveDate; +use ledger_core::arap::DocumentLine; +use ledger_core::{ + AccountType, Currency, DocRef, Ledger, LedgerError, Money, PaymentDetails, Side, +}; +use ledger_store::{Store, StoreError, snapshot::Snapshot}; +use proptest::prelude::*; +use rust_decimal::Decimal; +use std::path::{Path, PathBuf}; + +fn day(n: u8) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n)) +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir() -> TestDir { + use std::sync::atomic::{AtomicUsize, Ordering}; + static N: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "wanderbooks-arap-props-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +/// One step of a random history. +#[derive(Clone, Debug)] +enum Op { + /// An invoice (true) or a bill, on day `n`, for `c` cents. + Create(bool, u8, i64), + /// A payment on document `k` (mod the documents) on day `n`, for `c` + /// cents, by card or not, with a reference or not. + Pay(usize, u8, i64, bool, bool), + /// Reverse entry `k` (mod the posted entries) on day `n`. + Reverse(usize, u8), + /// Void document `k` on day `n`. + Void(usize, u8), + /// Close month `m` (January to March), in order. + Close(u8), + /// A hand line on receivables (true) or payables: always refused. + Hand(bool, i64), +} + +fn arb_op() -> impl Strategy { + prop_oneof![ + 3 => (any::(), 0u8..80, 1i64..50_000).prop_map(|(i, n, c)| Op::Create(i, n, c)), + 4 => (0usize..8, 0u8..90, 1i64..60_000, any::(), any::()) + .prop_map(|(k, n, c, card, r)| Op::Pay(k, n, c, card, r)), + 3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)), + 1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)), + 1 => (1u8..4).prop_map(Op::Close), + 1 => (any::(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)), + ] +} + +/// The books' fixed accounts: bank, card (and its payment method). +struct Setup { + bank: u64, + card: u64, + visa: u64, + customer: u64, + vendor: u64, +} + +fn setup(dir: &Path) -> (Store, Setup) { + let mut s = Store::create(dir, Currency::USD).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let card = s + .add_account("2050", "Card", AccountType::Liability, None, false) + .unwrap(); + s.add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let visa = s + .add_instrument( + ledger_core::InstrumentKind::BusinessCard, + "Fernhill Visa", + Some("4242"), + None, + card, + ) + .unwrap(); + let customer = s.add_customer("Fernhill Labs").unwrap(); + let vendor = s.add_vendor("Tobin Harlow Supply").unwrap(); + ( + s, + Setup { + bank, + card, + visa, + customer, + vendor, + }, + ) +} + +/// Run `ops` on the store; a refusal must journal nothing. +fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec) -> Result<(), TestCaseError> { + for op in ops { + let head = s.head(); + let line = |c: i64| { + vec![DocumentLine { + description: "work".into(), + amount: cents(c), + class_id: None, + }] + }; + let result: Result<(), StoreError> = match *op { + Op::Create(true, n, c) => s + .create_invoice(k.customer, day(n), day(n) + chrono::Days::new(30), line(c)) + .map(|i| docs.push(DocRef::Invoice(i))), + Op::Create(false, n, c) => s + .create_bill(k.vendor, day(n), day(n) + chrono::Days::new(30), line(c)) + .map(|i| docs.push(DocRef::Bill(i))), + Op::Pay(..) if docs.is_empty() => Ok(()), + Op::Pay(i, n, c, by_card, reference) => { + let details = PaymentDetails { + instrument: by_card.then_some(k.visa), + reference: reference.then(|| format!("trace {i}{n}")), + }; + match docs[i % docs.len()] { + DocRef::Invoice(id) => s + .receive_payment_with(id, day(n), cents(c), k.bank, &details) + .map(drop), + DocRef::Bill(id) => { + let from = if by_card { k.card } else { k.bank }; + s.pay_bill_with(id, day(n), cents(c), from, &details) + .map(drop) + } + } + } + Op::Reverse(i, n) => { + let posted: Vec = s.ledger().posted_entries().map(|e| e.id()).collect(); + match posted.get(i % posted.len().max(1)) { + Some(&e) => s.reverse_entry(e, day(n), "undo").map(drop), + None => Ok(()), + } + } + Op::Void(..) if docs.is_empty() => Ok(()), + Op::Void(i, n) => s + .void_document(docs[i % docs.len()], day(n), "duplicate") + .map(drop), + Op::Close(m) => { + let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap(); + let end = start + chrono::Months::new(1) - chrono::Days::new(1); + s.create_period(&format!("2026-{m:02}"), start, end) + .and_then(|p| { + let mid = s.head(); + s.close_period(p).map(drop).inspect_err(|_| { + // The period itself was journaled: only the + // close must leave nothing behind. + assert_eq!(s.head(), mid); + }) + }) + .map(drop) + } + Op::Hand(receivables, c) => { + let code = if receivables { "1200" } else { "2000" }; + match s.ledger().chart().find_by_code(code).map(|a| a.id) { + Some(a) if s.ledger().control_role_of(a).is_some() => { + // After every month the history can close. + let on = NaiveDate::from_ymd_opt(2026, 12, 1).unwrap(); + let e = s.create_entry(on, "hand").unwrap(); + let mid = s.head(); + let m = Money::new(cents(c), Currency::USD); + let r = s.add_line(e, a, Side::Debit, m); + prop_assert!( + matches!( + r, + Err(StoreError::Ledger(LedgerError::UntaggedControlLine { .. })) + ), + "{r:?}" + ); + prop_assert_eq!(s.head(), mid); + Ok(()) + } + _ => Ok(()), + } + } + }; + if result.is_err() && !matches!(op, Op::Close(_)) { + prop_assert_eq!(s.head(), head, "{:?} refused, yet journaled", op); + } + } + Ok(()) +} + +/// Every day a figure could change on, and the end. +fn days(l: &Ledger) -> Vec { + let mut v: Vec = l.posted_entries().map(|e| e.date()).collect(); + v.push(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()); + v.sort(); + v.dedup(); + v +} + +/// What the books say about their documents on day `on`: each state, the +/// control check and both agings, as one comparable value. +fn figures(l: &Ledger, docs: &[DocRef], on: NaiveDate) -> serde_json::Value { + let states: Vec<_> = docs.iter().map(|d| l.doc_state(*d, on).unwrap()).collect(); + serde_json::json!({ + "states": states, + "control": l.control_check(on).unwrap(), + "ar": l.ar_aging(on).unwrap(), + "ap": l.ap_aging(on).unwrap(), + }) +} + +/// The live books, their replay and a snapshot agree; the control +/// accounts equal their documents, each within bounds, at every date. +fn agree(live: &Ledger, dir: &Path, docs: &[DocRef]) -> Result<(), TestCaseError> { + let replayed = Store::open(dir).unwrap(); + let snap = Snapshot::load(dir).unwrap(); + prop_assert!( + live.differs_from(replayed.ledger()).is_empty(), + "replay differs: {:?}", + live.differs_from(replayed.ledger()) + ); + prop_assert!( + live.differs_from(snap.ledger()).is_empty(), + "snapshot differs: {:?}", + live.differs_from(snap.ledger()) + ); + for on in days(live) { + let f = figures(live, docs, on); + prop_assert_eq!( + &f, + &figures(replayed.ledger(), docs, on), + "replay on {}", + on + ); + prop_assert_eq!(&f, &figures(snap.ledger(), docs, on), "snapshot on {}", on); + for c in live.control_check(on).unwrap() { + prop_assert_eq!(c.difference, Decimal::ZERO); + prop_assert!(c.unattached.is_empty() && c.attention.is_empty(), "{:?}", c); + } + for d in docs { + if let Some(st) = live.doc_state(*d, on).unwrap() { + prop_assert!(!st.out_of_bounds(), "{d} on {on}: {st:?}"); + } + } + } + replayed.verify().unwrap(); + Ok(()) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + + /// B-38 INVARIANT 4 (and 1, 2, 6 through the store): a document's + /// state is a function of the journal and the day. Half the history, + /// then the books reopened and compared; the rest on the reopened + /// books, compared again. + #[test] + fn status_is_a_function_of_journal_and_date( + first in prop::collection::vec(arb_op(), 0..18), + then in prop::collection::vec(arb_op(), 0..18), + ) { + let dir = test_dir(); + let (mut s, k) = setup(&dir.0); + let mut docs = Vec::new(); + run(&mut s, &k, &first, &mut docs)?; + let live = s.ledger().clone(); + drop(s); + agree(&live, &dir.0, &docs)?; + let mut s = Store::open(&dir.0).unwrap(); + run(&mut s, &k, &then, &mut docs)?; + let live = s.ledger().clone(); + drop(s); + agree(&live, &dir.0, &docs)?; + } +} diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs new file mode 100644 index 0000000..bbf582e --- /dev/null +++ b/crates/ledger-store/tests/arap_standing.rs @@ -0,0 +1,613 @@ +//! Documents read their figures from the ledger lines tagged with them +//! (plan P4b S1, D-45; rules.md B-38): the books an earlier version wrote, +//! replayed, and the golden figures that version showed for them. +//! +//! The fixture `tests/fixtures/p4-arap-before` is a books dir written by +//! the version before S1 (main at 0df7f52a), and `figures.json` beside it +//! is what that version reported for it. This version opens the same +//! journal; every figure that moves is adjudicated here, cent by cent +//! (P4b.14 item 2): only documents whose entry or payment was reversed +//! change, and the general ledger doesn't move at all. + +use chrono::NaiveDate; +use ledger_core::arap::DocumentLine; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::{JOURNAL_FILE, Store}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn line(what: &str, c: i64) -> DocumentLine { + DocumentLine { + description: what.into(), + amount: cents(c), + class_id: None, + } +} + +/// The dates the golden figures are taken at: between a payment and its +/// reversal, a month end, and the end of the books. +const GOLDEN_DATES: [(i32, u32, u32); 3] = [(2026, 1, 22), (2026, 1, 31), (2026, 3, 31)]; + +/// Write the fixture's books into `dir`, through the Store's commands as +/// the version before S1 had them: two customers' invoices (one paid then +/// the payment reversed, one reversed outright, one part paid), a vendor's +/// bills (one paid then the payment reversed), and a hand entry on +/// receivables. Only a version before S1 can (S1 refuses the hand entry). +fn write_books(dir: &Path) { + let mut s = Store::create(dir, Currency::USD).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let seed = s.create_entry(d(2026, 1, 1), "Opening capital").unwrap(); + let m = Money::new(Decimal::from(1000), Currency::USD); + s.add_line(seed, bank, Side::Debit, m).unwrap(); + s.add_line(seed, capital, Side::Credit, m).unwrap(); + s.post_entry(seed).unwrap(); + let fernhill = s.add_customer("Fernhill Labs").unwrap(); + let morgan = s.add_customer("Morgan Ellis Studio").unwrap(); + let vendor = s.add_vendor("Tobin Harlow Supply").unwrap(); + // Invoice 1: paid in full, then the payment reversed (a bounced check). + let i1 = s + .create_invoice( + fernhill, + d(2026, 1, 10), + d(2026, 2, 9), + vec![line("Design", 10_000)], + ) + .unwrap(); + let p1 = s + .receive_payment(i1, d(2026, 1, 20), cents(10_000), bank) + .unwrap(); + s.reverse_entry(p1, d(2026, 1, 25), "Check bounced") + .unwrap(); + // Invoice 2: issued to the wrong customer and reversed (a void). + let i2 = s + .create_invoice( + morgan, + d(2026, 2, 1), + d(2026, 3, 3), + vec![line("Wrong customer", 20_000)], + ) + .unwrap(); + let e2 = s.ledger().get_invoice(i2).unwrap().entry_id; + s.reverse_entry(e2, d(2026, 2, 5), "Void: wrong customer") + .unwrap(); + // Invoice 3: part paid, nothing reversed. + let i3 = s + .create_invoice( + morgan, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("Workshop", 30_000)], + ) + .unwrap(); + s.receive_payment(i3, d(2026, 3, 10), cents(12_000), bank) + .unwrap(); + // Bill 1: paid, then the payment reversed (sent to the wrong vendor). + let b1 = s + .create_bill( + vendor, + d(2026, 1, 15), + d(2026, 2, 14), + vec![line("Paper", 8_000)], + ) + .unwrap(); + let q1 = s.pay_bill(b1, d(2026, 1, 20), cents(8_000), bank).unwrap(); + s.reverse_entry(q1, d(2026, 1, 28), "Paid in error") + .unwrap(); + // Bill 2: open. + s.create_bill( + vendor, + d(2026, 2, 10), + d(2026, 3, 12), + vec![line("Ink", 4_000)], + ) + .unwrap(); + // A hand entry on receivables, no document behind it. + let ar = s.ledger().chart().find_by_code("1200").unwrap().id; + let revenue = s.ledger().chart().find_by_code("4000").unwrap().id; + let hand = s.create_entry(d(2026, 2, 15), "Hand-booked sale").unwrap(); + let m = Money::new(cents(2_500), Currency::USD); + s.add_line(hand, ar, Side::Debit, m).unwrap(); + s.add_line(hand, revenue, Side::Credit, m).unwrap(); + s.post_entry(hand).unwrap(); +} + +/// An aging report as JSON: each row's name and buckets, as strings. +fn aging_json(rows: &[ledger_core::AgingRow]) -> Value { + Value::Array( + rows.iter() + .map(|r| { + json!({ + "name": r.name, + "current": r.current.normalize().to_string(), + "days_30": r.days_30.normalize().to_string(), + "days_60": r.days_60.normalize().to_string(), + "days_90": r.days_90.normalize().to_string(), + "days_90_plus": r.days_90_plus.normalize().to_string(), + }) + }) + .collect(), + ) +} + +/// The trial balance as JSON: each account's code and signed balance. +fn trial_balance_json(s: &Store) -> Value { + let tb = ledger_core::reports::trial_balance(s.ledger()); + Value::Array( + tb.lines + .iter() + .map(|l| { + json!({ + "code": l.code, + "debit": l.debit.normalize().to_string(), + "credit": l.credit.normalize().to_string(), + }) + }) + .collect(), + ) +} + +/// How tests/fixtures/p4-arap-before was written, and its golden figures +/// taken: by the version before S1. Run with WB_WRITE_P4_ARAP_FIXTURE=DIR +/// on that version (S1 refuses the fixture's hand entry on receivables). +#[test] +fn write_the_p4_arap_fixture() { + let Ok(out) = std::env::var("WB_WRITE_P4_ARAP_FIXTURE") else { + return; + }; + assert!( + !ledger_store::format::KNOWN_FEATURES.contains(&"document_standing"), + "only a version before S1 writes it" + ); + let out = PathBuf::from(out); + write_books(&out); + let s = Store::open(&out).unwrap(); + let mut at = serde_json::Map::new(); + for (y, m, day) in GOLDEN_DATES { + let on = d(y, m, day); + at.insert( + on.to_string(), + json!({ + "ar_aging": aging_json(&s.ledger().ar_aging(on).unwrap()), + "ap_aging": aging_json(&s.ledger().ap_aging(on).unwrap()), + }), + ); + } + let invoices: Vec = s + .ledger() + .invoices() + .iter() + .map(|i| { + json!({"id": i.id, "balance": i.balance().unwrap().normalize().to_string(), + "status": format!("{:?}", i.status)}) + }) + .collect(); + let bills: Vec = s + .ledger() + .bills() + .iter() + .map(|b| { + json!({"id": b.id, "balance": b.balance().unwrap().normalize().to_string(), + "status": format!("{:?}", b.status)}) + }) + .collect(); + let figures = json!({ + "written_by": "wanderbooks main 0df7f52a, before P4b S1", + "at": at, + "invoices": invoices, + "bills": bills, + "trial_balance": trial_balance_json(&s), + }); + drop(s); + std::fs::write( + out.join("figures.json"), + serde_json::to_string_pretty(&figures).unwrap() + "\n", + ) + .unwrap(); + for f in [ledger_store::LOCK_FILE, &ledger_store::db_file()] { + let _ = std::fs::remove_file(out.join(f)); + } + let _ = std::fs::remove_dir(out.join("blobs")); + assert!(out.join(JOURNAL_FILE).exists()); +} + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + let dir = std::env::temp_dir().join(format!( + "wanderbooks-arap-standing-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +/// A copy of the fixture's journal (its projection is built on open). +fn fixture(name: &str) -> TestDir { + let dir = test_dir(name); + let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p4-arap-before"); + std::fs::copy(from.join(JOURNAL_FILE), dir.0.join(JOURNAL_FILE)).unwrap(); + dir +} + +fn golden() -> Value { + serde_json::from_str(include_str!("fixtures/p4-arap-before/figures.json")).unwrap() +} + +fn journal_len(dir: &Path) -> usize { + std::fs::read_to_string(dir.join(JOURNAL_FILE)) + .unwrap() + .lines() + .count() +} + +/// The lines journaled from `from` on, as (type, payload). +fn since(dir: &Path, from: usize) -> Vec<(String, Value)> { + std::fs::read_to_string(dir.join(JOURNAL_FILE)) + .unwrap() + .lines() + .skip(from) + .map(|l| { + let v: Value = serde_json::from_str(l).unwrap(); + ( + v["type"].as_str().unwrap().to_string(), + v["payload"].clone(), + ) + }) + .collect() +} + +/// The fixture an earlier version wrote opens, verifies, and shows each +/// figure that version got wrong, right (P4b.14 item 2, adjudicated cent +/// by cent against figures.json): +/// +/// - The general ledger is the golden's, line for line: the GL already +/// held every reversal. +/// - Jan 22, between the payments and their reversals: as the golden +/// (both documents paid then). +/// - Jan 31: the golden showed nothing owed; Fernhill's invoice 1 owes +/// 100.00 again (its payment reversed Jan 25) and Tobin's bill 1 80.00 +/// (its payment reversed Jan 28), both current. +/// - Mar 31: the golden aged Morgan's voided invoice 2 (200.00, 1-30) +/// and left Fernhill out; now Morgan owes only invoice 3's 180.00 +/// (current), Fernhill 100.00 at 50 days past due (31-60), and Tobin +/// 80.00 at 45 days (31-60) beside bill 2's 40.00 (1-30). +/// - Receivables 305.00 = the invoices' 280.00 + the hand line's 25.00, +/// unattached. +#[test] +fn legacy_reversal_now_counts() { + let dir = fixture("golden"); + let s = Store::open(&dir.0).unwrap(); + s.verify().unwrap(); + let g = golden(); + assert_eq!( + trial_balance_json(&s), + g["trial_balance"], + "the GL doesn't move" + ); + let on = |y, m, day| d(y, m, day).to_string(); + let at = |date: String| g["at"][&date].clone(); + let now = |date: NaiveDate| { + json!({ + "ar_aging": aging_json(&s.ledger().ar_aging(date).unwrap()), + "ap_aging": aging_json(&s.ledger().ap_aging(date).unwrap()), + }) + }; + assert_eq!(now(d(2026, 1, 22)), at(on(2026, 1, 22)), "unchanged"); + let row = |name: &str, current: &str, d30: &str, d60: &str| { + json!({"name": name, "current": current, "days_30": d30, "days_60": d60, + "days_90": "0", "days_90_plus": "0"}) + }; + assert_eq!(at(on(2026, 1, 31)), json!({"ar_aging": [], "ap_aging": []})); + assert_eq!( + now(d(2026, 1, 31)), + json!({ + "ar_aging": [row("Fernhill Labs", "100", "0", "0")], + "ap_aging": [row("Tobin Harlow Supply", "80", "0", "0")], + }) + ); + assert_eq!( + at(on(2026, 3, 31))["ar_aging"], + json!([row("Morgan Ellis Studio", "180", "200", "0")]) + ); + assert_eq!( + now(d(2026, 3, 31)), + json!({ + "ar_aging": [ + row("Fernhill Labs", "0", "0", "100"), + row("Morgan Ellis Studio", "180", "0", "0"), + ], + "ap_aging": [row("Tobin Harlow Supply", "0", "40", "80")], + }) + ); + let state = |doc| s.ledger().doc_state(doc, d(2026, 12, 31)).unwrap().unwrap(); + use ledger_core::{DocRef, DocStatus}; + for (doc, status, owed) in [ + (DocRef::Invoice(1), DocStatus::Open, 10_000), + (DocRef::Invoice(2), DocStatus::Void, 0), + (DocRef::Invoice(3), DocStatus::Partial, 18_000), + (DocRef::Bill(1), DocStatus::Open, 8_000), + (DocRef::Bill(2), DocStatus::Open, 4_000), + ] { + let st = state(doc); + assert_eq!((st.status, st.outstanding), (status, cents(owed)), "{doc}"); + } + let checks = s.ledger().control_check(d(2026, 12, 31)).unwrap(); + let ar = checks.iter().find(|c| c.role == "receivables").unwrap(); + assert_eq!( + (ar.balance, ar.documents_total, ar.difference), + (cents(30_500), cents(28_000), Decimal::ZERO) + ); + assert_eq!(ar.unattached.len(), 1); + assert_eq!(ar.unattached[0].amount, cents(2_500)); + let ap = checks.iter().find(|c| c.role == "payables").unwrap(); + assert_eq!( + (ap.balance, ap.documents_total, ap.unattached.len()), + (cents(12_000), cents(12_000), 0) + ); + // Opening wrote nothing. + drop(s); + let again = Store::open(&dir.0).unwrap(); + assert_eq!(trial_balance_json(&again), g["trial_balance"]); +} + +/// B-38 scenario 5 and item 7 in books an earlier version keeps: the +/// right payment after the wrong one was reversed waits for the journal +/// feature document_standing (an older version would count both), +/// refused and journaling nothing until the owner's upgrade; a payment +/// any version reads alike is taken at once; after the upgrade the +/// re-payment is taken, and the books replay to what was live. +#[test] +fn repaying_after_a_reversal_waits_for_document_standing() { + use ledger_store::StoreError; + let dir = fixture("standing"); + let mut s = Store::open(&dir.0).unwrap(); + assert!( + !s.journal_format() + .has_feature(ledger_store::format::DOCUMENT_STANDING) + ); + let bank = s.ledger().chart().find_by_code("1000").unwrap().id; + let head = s.head(); + match s.receive_payment(1, d(2026, 2, 1), cents(10_000), bank) { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature document_standing") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + assert!(matches!( + s.pay_bill(1, d(2026, 2, 1), cents(8_000), bank), + Err(StoreError::FormatUpgradePending { .. }) + )); + assert_eq!(s.head(), head, "nothing journaled"); + // Invoice 3: nothing reversed on it, so any version reads it alike. + s.receive_payment(3, d(2026, 3, 20), cents(18_000), bank) + .unwrap(); + assert!(s.upgrade_format().unwrap().is_some()); + s.receive_payment(1, d(2026, 2, 1), cents(10_000), bank) + .unwrap(); + s.pay_bill(1, d(2026, 2, 1), cents(8_000), bank).unwrap(); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + let reopened = Store::open(&dir.0).unwrap(); + assert!( + live.differs_from(reopened.ledger()).is_empty(), + "{:?}", + live.differs_from(reopened.ledger()) + ); + let st = reopened + .ledger() + .invoice_state(1, d(2026, 12, 31)) + .unwrap() + .unwrap(); + assert_eq!(st.status, ledger_core::DocStatus::Paid); +} + +/// The control guard through the store, and a void under a payment: +/// refused, journaling nothing (B-38 scenarios 3 and 6). +#[test] +fn the_store_refuses_hand_lines_and_a_void_under_a_payment() { + let dir = fixture("guard"); + let mut s = Store::open(&dir.0).unwrap(); + let ar = s.ledger().chart().find_by_code("1200").unwrap().id; + let e = s.create_entry(d(2026, 3, 1), "by hand").unwrap(); + let head = s.head(); + let m = Money::new(cents(100), Currency::USD); + assert!(matches!( + s.add_line(e, ar, Side::Debit, m), + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::UntaggedControlLine { .. } + )) + )); + assert!( + s.void_document(ledger_core::DocRef::Invoice(3), d(2026, 3, 31), "dup") + .is_err() + ); + let inv3 = s.ledger().get_invoice(3).unwrap().entry_id; + assert!(s.reverse_entry(inv3, d(2026, 3, 31), "Void").is_err()); + assert_eq!(s.head(), head, "nothing journaled"); + // The remedy: reverse the payment, then the void is taken. + let pay = s + .ledger() + .standing_payments(ledger_core::DocRef::Invoice(3)); + assert_eq!(pay.len(), 1); + s.reverse_entry(pay[0], d(2026, 3, 30), "Refunded").unwrap(); + s.void_document(ledger_core::DocRef::Invoice(3), d(2026, 3, 31), "duplicate") + .unwrap(); + s.verify().unwrap(); +} + +/// A void writes no event of its own: it is the entry.reversed of the +/// document's entry, described "Void invoice N: reason" (P4b.7), and +/// replays to the same books. +#[test] +fn a_void_is_journaled_as_its_reversal() { + let dir = test_dir("void"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let c = s.add_customer("Fernhill Labs").unwrap(); + let inv = s + .create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 5_000)]) + .unwrap(); + let origin = s.ledger().get_invoice(inv).unwrap().entry_id; + let from = journal_len(&dir.0); + let r = s + .void_document( + ledger_core::DocRef::Invoice(inv), + d(2026, 4, 2), + "duplicate", + ) + .unwrap(); + let got = since(&dir.0, from); + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0].0, "entry.reversed"); + assert_eq!(got[0].1["id"], json!(r)); + assert_eq!(got[0].1["reverses"], json!(origin)); + assert_eq!( + got[0].1["description"], + json!(format!("Void invoice {inv}: duplicate")) + ); + let live = s.ledger().clone(); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!(live.differs_from(s.ledger()).is_empty()); + let st = s + .ledger() + .invoice_state(inv, d(2026, 4, 30)) + .unwrap() + .unwrap(); + assert_eq!(st.status, ledger_core::DocStatus::Void); + s.verify().unwrap(); +} + +/// A payment with its method and reference: the reference in its +/// entry's description, the method as entry.instrument_set right after +/// the payment event (feature payment_instruments); a payment with +/// neither is journaled exactly as before, field for field. +#[test] +fn a_payment_journals_its_method_and_reference() { + let dir = test_dir("method"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let card = s + .add_account("2050", "Card", AccountType::Liability, None, false) + .unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let v = s.add_vendor("Tobin Harlow Supply").unwrap(); + let c = s.add_customer("Fernhill Labs").unwrap(); + let visa = s + .add_instrument( + ledger_core::InstrumentKind::BusinessCard, + "Visa", + Some("4242"), + None, + card, + ) + .unwrap(); + let bill = s + .create_bill(v, d(2026, 4, 1), d(2026, 5, 1), vec![line("Paper", 2_000)]) + .unwrap(); + let inv = s + .create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 3_000)]) + .unwrap(); + let from = journal_len(&dir.0); + let details = ledger_core::PaymentDetails { + instrument: Some(visa), + reference: Some("ch_3Nx".into()), + }; + let e = s + .pay_bill_with(bill, d(2026, 4, 3), cents(2_000), card, &details) + .unwrap(); + let got = since(&dir.0, from); + let types: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!( + types, + [ + "entry.created", + "entry.line_added", + "entry.line_added", + "entry.posted", + "bill.paid", + "entry.instrument_set" + ] + ); + assert_eq!( + got[0].1["description"], + json!(format!("Payment on bill {bill} (ref ch_3Nx)")) + ); + assert_eq!(s.ledger().entry_instrument(e), Some(visa)); + let from = journal_len(&dir.0); + s.receive_payment(inv, d(2026, 4, 4), cents(3_000), bank) + .unwrap(); + let got = since(&dir.0, from); + assert_eq!(got.len(), 5, "{got:?}"); + let keys: Vec<&String> = got[4].1.as_object().unwrap().keys().collect(); + assert_eq!( + keys, + ["amount", "cash_account", "date", "entry_id", "invoice_id"] + ); + let live = s.ledger().clone(); + drop(s); + let s = Store::open(&dir.0).unwrap(); + assert!(live.differs_from(s.ledger()).is_empty()); + s.verify().unwrap(); +} + +/// Tags are derived on replay, nothing journaled for them: the fixture's +/// invoice 1 carries its receivable line (Origin) and its payment's, the +/// payment's reversal its mirror; the hand line none. +#[test] +fn legacy_documents_replay_with_tags() { + use ledger_core::{DocRef, Movement}; + let dir = fixture("tags"); + let s = Store::open(&dir.0).unwrap(); + let l = s.ledger(); + let e = l.get_invoice(1).unwrap().entry_id; + let tag = l.line_doc(e, 0).unwrap(); + assert_eq!( + (tag.doc, tag.movement), + (DocRef::Invoice(1), Movement::Origin) + ); + let lines = l.doc_lines(DocRef::Invoice(1)); + assert_eq!( + lines + .iter() + .map(|x| (x.movement, x.reversal)) + .collect::>(), + [ + (Movement::Origin, false), + (Movement::Payment, false), + (Movement::Payment, true) + ] + ); + let bill = l.get_bill(1).unwrap().entry_id; + let last = l.get_entry(bill).unwrap().lines().len() - 1; + assert_eq!(l.line_doc(bill, last).unwrap().doc, DocRef::Bill(1)); + let hand = l + .posted_entries() + .find(|e| e.description() == "Hand-booked sale") + .unwrap() + .id(); + assert!((0..2).all(|i| l.line_doc(hand, i).is_none())); +} diff --git a/crates/ledger-store/tests/fixtures/p4-arap-before/figures.json b/crates/ledger-store/tests/fixtures/p4-arap-before/figures.json new file mode 100644 index 0000000..ce5476a --- /dev/null +++ b/crates/ledger-store/tests/fixtures/p4-arap-before/figures.json @@ -0,0 +1,96 @@ +{ + "at": { + "2026-01-22": { + "ap_aging": [], + "ar_aging": [] + }, + "2026-01-31": { + "ap_aging": [], + "ar_aging": [] + }, + "2026-03-31": { + "ap_aging": [ + { + "current": "0", + "days_30": "40", + "days_60": "0", + "days_90": "0", + "days_90_plus": "0", + "name": "Tobin Harlow Supply" + } + ], + "ar_aging": [ + { + "current": "180", + "days_30": "200", + "days_60": "0", + "days_90": "0", + "days_90_plus": "0", + "name": "Morgan Ellis Studio" + } + ] + } + }, + "bills": [ + { + "balance": "0", + "id": 1, + "status": "Paid" + }, + { + "balance": "40", + "id": 2, + "status": "Open" + } + ], + "invoices": [ + { + "balance": "0", + "id": 1, + "status": "Paid" + }, + { + "balance": "200", + "id": 2, + "status": "Open" + }, + { + "balance": "180", + "id": 3, + "status": "Partial" + } + ], + "trial_balance": [ + { + "code": "1000", + "credit": "0", + "debit": "1120" + }, + { + "code": "1200", + "credit": "0", + "debit": "305" + }, + { + "code": "2000", + "credit": "120", + "debit": "0" + }, + { + "code": "3000", + "credit": "1000", + "debit": "0" + }, + { + "code": "4000", + "credit": "425", + "debit": "0" + }, + { + "code": "6999", + "credit": "0", + "debit": "120" + } + ], + "written_by": "wanderbooks main 0df7f52a, before P4b S1" +} diff --git a/crates/ledger-store/tests/fixtures/p4-arap-before/journal.jsonl b/crates/ledger-store/tests/fixtures/p4-arap-before/journal.jsonl new file mode 100644 index 0000000..b35ace8 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/p4-arap-before/journal.jsonl @@ -0,0 +1,62 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-23T17:49:14Z","type":"store.created"} +{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"eb6e78d8d00bed1be9a59e3c07c1423083ddbaf9beee4ff9da1106628faef362","seq":2,"ts":"2026-10-23T17:49:14Z","type":"journal.format_set"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"9f985787d31957f133c73041a0a8a284653cdd863923dde12976a6d5d69513ea","seq":3,"ts":"2026-10-23T17:49:14Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"b08a6f2026e9f34347f553e40ec59e3123c7f1a62646ef8c3c80a41df2579a22","seq":4,"ts":"2026-10-23T17:49:14Z","type":"account.added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"dea25bc2fdbac79fc3b058bcf7b62459b10a7b582d2295b8c932c692e557cacb","seq":5,"ts":"2026-10-23T17:49:14Z","type":"entry.created"} +{"payload":{"account":1,"amount":"1000","currency":"USD","id":1,"side":"debit"},"prev":"fc8968c9ef6778af950e7d47295803ebc762fd2146732c38f1891b550e276b69","seq":6,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"1000","currency":"USD","id":1,"side":"credit"},"prev":"7c8eab52ac9ab71ff98665d96f3c706fa4ac114a6fecb452e8006020c2a2daf7","seq":7,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000","currency":"USD","side":"debit"},{"account":2,"amount":"1000","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8179f4c64b537508e3297aeef4acbec685cf0d56c172db52cb16f253e87e80db","seq":8,"ts":"2026-10-23T17:49:14Z","type":"entry.posted"} +{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"ec1c5635e2bfa8a9683e3c06f4e68bb2e86abf47558da91e7977847bd9b9d18e","seq":9,"ts":"2026-10-23T17:49:14Z","type":"customer.added"} +{"payload":{"id":2,"name":"Morgan Ellis Studio"},"prev":"5db484ed2a315872e20f67497fef853bec9829d4854f4fe911196982b3822930","seq":10,"ts":"2026-10-23T17:49:14Z","type":"customer.added"} +{"payload":{"id":1,"name":"Tobin Harlow Supply"},"prev":"3bd354891c4a3da6f70fe93b6da6e199dde6e6b32c09598d815129b7905cc414","seq":11,"ts":"2026-10-23T17:49:14Z","type":"vendor.added"} +{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":3,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"6ed860b583ebfcb6d190be0e40662bc4e91a66873d0cb554fbeee3255374e556","seq":12,"ts":"2026-10-23T17:49:14Z","type":"account.added"} +{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Revenue","parent":null,"placeholder":false},"prev":"12e4262f9e11bd6e20a91109dd5e5e29c98628a1fff26b4bc76cfce809611849","seq":13,"ts":"2026-10-23T17:49:14Z","type":"account.added"} +{"payload":{"date":"2026-01-10","description":"Invoice 1","id":2},"prev":"4caf31b7426bacb7ff48b6c7c2ecb82f0d932fb2ad43ed0d2508eeb1661e3d6a","seq":14,"ts":"2026-10-23T17:49:14Z","type":"entry.created"} +{"payload":{"account":3,"amount":"100.00","currency":"USD","id":2,"side":"debit"},"prev":"43933d9c0657e03fe38b658a6ac8b7ed55de7f57303434ec54895ef8342eeafc","seq":15,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"} +{"payload":{"account":4,"amount":"100.00","currency":"USD","id":2,"side":"credit"},"prev":"725d36996f9e49adef4d8591ce59fbcb56c6815badfd4857b483b96350984a27","seq":16,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-10","description":"Invoice 1","id":2,"lines":[{"account":3,"amount":"100.00","currency":"USD","side":"debit"},{"account":4,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"43521bd0b41d487b95e85582082664e982626fef4f41d18f5617669eb6c19e9a","seq":17,"ts":"2026-10-23T17:49:14Z","type":"entry.posted"} +{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":2,"id":1,"lines":[{"amount":"100.00","description":"Design"}]},"prev":"896e5a852057640d244350dd6c762bc0765bea135a01a845f753046f395a81ff","seq":18,"ts":"2026-10-23T17:49:14Z","type":"invoice.created"} +{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":3},"prev":"18202fcc839f243d5091d15a63190b2731d40f04a527585f19365dc68c9ea884","seq":19,"ts":"2026-10-23T17:49:14Z","type":"entry.created"} +{"payload":{"account":1,"amount":"100.00","currency":"USD","id":3,"side":"debit"},"prev":"aa81e39741d2389c28d5f514493b0b409c5cb2197735ce63c47413e9da5a3a30","seq":20,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"} +{"payload":{"account":3,"amount":"100.00","currency":"USD","id":3,"side":"credit"},"prev":"cc0461c19c921b59a1f1f07664fcc9ce331b96fe8cdd17fadf162ee0473ed63d","seq":21,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":3,"lines":[{"account":1,"amount":"100.00","currency":"USD","side":"debit"},{"account":3,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c5f3ab8a361fb24b70998c38e13822c26f9693c7002841f954434bf32b220bc2","seq":22,"ts":"2026-10-23T17:49:14Z","type":"entry.posted"} +{"payload":{"amount":"100.00","cash_account":1,"date":"2026-01-20","entry_id":3,"invoice_id":1},"prev":"8b407977fa9b6787b07b4a943f7714fd6357deddf17ad0344130cb6a3cd294e2","seq":23,"ts":"2026-10-23T17:49:14Z","type":"invoice.payment_received"} +{"payload":{"date":"2026-01-25","description":"Check bounced","id":4,"lines":[{"account":1,"amount":"100.00","currency":"USD","side":"credit"},{"account":3,"amount":"100.00","currency":"USD","side":"debit"}],"reverses":3},"prev":"de7a16daea36b5b580c8863d3aed63eeebf4ab410011f1484a31fd495bb2a5c5","seq":24,"ts":"2026-10-23T17:49:14Z","type":"entry.reversed"} +{"payload":{"date":"2026-02-01","description":"Invoice 2","id":5},"prev":"fef4279c55719dccb847fd6ec2bf8cf0443f828939cb60b7d5e33d9bebbdfb02","seq":25,"ts":"2026-10-23T17:49:14Z","type":"entry.created"} +{"payload":{"account":3,"amount":"200.00","currency":"USD","id":5,"side":"debit"},"prev":"e385985b3ed74ced6eab48ac7b93f46a5bf76a11fcdd4c2b70449ebbffa68b13","seq":26,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"account":4,"amount":"200.00","currency":"USD","id":5,"side":"credit"},"prev":"4940c3c4b730cf67b51aed3aaf98942660ceeade217cc4d1e62bef65096421b8","seq":27,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-01","description":"Invoice 2","id":5,"lines":[{"account":3,"amount":"200.00","currency":"USD","side":"debit"},{"account":4,"amount":"200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8ea332be289c7733a31aa2b1c50a725fbd86bd72ec06f04227149e75a4c7e48a","seq":28,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"} +{"payload":{"customer_id":2,"date":"2026-02-01","due_date":"2026-03-03","entry_id":5,"id":2,"lines":[{"amount":"200.00","description":"Wrong customer"}]},"prev":"6d557342119cf5defa10fe1c99714b191b78c6881ea4b7b9221e0753327f0c78","seq":29,"ts":"2026-10-23T17:49:15Z","type":"invoice.created"} +{"payload":{"date":"2026-02-05","description":"Void: wrong customer","id":6,"lines":[{"account":3,"amount":"200.00","currency":"USD","side":"credit"},{"account":4,"amount":"200.00","currency":"USD","side":"debit"}],"reverses":5},"prev":"a28ac5a9be20a1e5e1660628a1cc2098f06bb671365e2c14599eb805d34d6df5","seq":30,"ts":"2026-10-23T17:49:15Z","type":"entry.reversed"} +{"payload":{"date":"2026-03-01","description":"Invoice 3","id":7},"prev":"817f1ab3784a95a37c66f0b6c08b6bf7c6181199a7b6cd1f2826312c7dafa3aa","seq":31,"ts":"2026-10-23T17:49:15Z","type":"entry.created"} +{"payload":{"account":3,"amount":"300.00","currency":"USD","id":7,"side":"debit"},"prev":"a6efe165c18aa3e98c41d9379aca2c5bce495e96eedb542265aa68df1b84f45f","seq":32,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"account":4,"amount":"300.00","currency":"USD","id":7,"side":"credit"},"prev":"cfe0f607c0fc65cd6a084022ffd860c5484e7497f1284edff4b8fcd364456e86","seq":33,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-03-01","description":"Invoice 3","id":7,"lines":[{"account":3,"amount":"300.00","currency":"USD","side":"debit"},{"account":4,"amount":"300.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5760d58c9ad06005d9b9872ab79f32fe49d6d0863bd48ae0d791a224738362f9","seq":34,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"} +{"payload":{"customer_id":2,"date":"2026-03-01","due_date":"2026-03-31","entry_id":7,"id":3,"lines":[{"amount":"300.00","description":"Workshop"}]},"prev":"6cbb0e41c1c5d6070bc543cd3164cfc16a0d673e1d0f196b28077fae08c3d24d","seq":35,"ts":"2026-10-23T17:49:15Z","type":"invoice.created"} +{"payload":{"date":"2026-03-10","description":"Payment on invoice 3","id":8},"prev":"d9d6986ff6735a1d54dabac27c9e32194870cec5a485f3809a9d9dab05949fe2","seq":36,"ts":"2026-10-23T17:49:15Z","type":"entry.created"} +{"payload":{"account":1,"amount":"120.00","currency":"USD","id":8,"side":"debit"},"prev":"b02a85dcdf99109a1ad6dde3297381ed0cd0d0fe22f8320b4d5cd0b395453233","seq":37,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"account":3,"amount":"120.00","currency":"USD","id":8,"side":"credit"},"prev":"6df3a9fdb02a4fd1299fda857469f7de9eae54a9cb2dafba0b3cea824e065f8b","seq":38,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-03-10","description":"Payment on invoice 3","id":8,"lines":[{"account":1,"amount":"120.00","currency":"USD","side":"debit"},{"account":3,"amount":"120.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"cf15a58d2636aa9c9aab41500a03b490f604e83baed37b90cbe4561b00e64956","seq":39,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"} +{"payload":{"amount":"120.00","cash_account":1,"date":"2026-03-10","entry_id":8,"invoice_id":3},"prev":"84f0e3f69aa2e4333a231f63160b588e835b7aa82d4e429244fbe6caae210b60","seq":40,"ts":"2026-10-23T17:49:15Z","type":"invoice.payment_received"} +{"payload":{"account_type":"Expense","code":"6999","currency":"USD","id":5,"name":"General Expenses","parent":null,"placeholder":false},"prev":"d312dbf6968c2f90f15b79f3d90483e293ab12c6f13f23509e54994d70e5459a","seq":41,"ts":"2026-10-23T17:49:15Z","type":"account.added"} +{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts Payable","parent":null,"placeholder":false},"prev":"68d3ece17cb34265b91b7860c3d195c7e4dfe356ae28d6f9bb6868c8776e7d0e","seq":42,"ts":"2026-10-23T17:49:15Z","type":"account.added"} +{"payload":{"date":"2026-01-15","description":"Bill 1","id":9},"prev":"105043103a6a765ff38955e62e1239fa361aae9f3e911445da856de579ce3e6a","seq":43,"ts":"2026-10-23T17:49:15Z","type":"entry.created"} +{"payload":{"account":5,"amount":"80.00","currency":"USD","id":9,"side":"debit"},"prev":"0946afac7673374597c5172e6feeaf6ac339726b105faec8dea8e2e015b4bcf2","seq":44,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"account":6,"amount":"80.00","currency":"USD","id":9,"side":"credit"},"prev":"1d514a116ab96f78f6deede2255d9a2ac89df843721443ca42daa5749afc1d8a","seq":45,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-15","description":"Bill 1","id":9,"lines":[{"account":5,"amount":"80.00","currency":"USD","side":"debit"},{"account":6,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f4ef92842aa02735fa0e6c63c7b1e9fb3d5bc131d114b5fdfa7315ea5f8a6477","seq":46,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"} +{"payload":{"date":"2026-01-15","due_date":"2026-02-14","entry_id":9,"id":1,"lines":[{"amount":"80.00","description":"Paper"}],"vendor_id":1},"prev":"b36e0445d8c6794d62c8a7c743030142d1e0393f04e3972d2bc7544bf789a7db","seq":47,"ts":"2026-10-23T17:49:15Z","type":"bill.created"} +{"payload":{"date":"2026-01-20","description":"Payment on bill 1","id":10},"prev":"cc86b398d183f7630fabe1fc0e91cc1bd286d28e2cecfd6d063ad36915c99dcb","seq":48,"ts":"2026-10-23T17:49:15Z","type":"entry.created"} +{"payload":{"account":6,"amount":"80.00","currency":"USD","id":10,"side":"debit"},"prev":"b91a8933d4c93ca33e0386b5b3b0a2eeffa66ab643951c96d909325724c74e70","seq":49,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"80.00","currency":"USD","id":10,"side":"credit"},"prev":"b55247fffbfd0c6c229930630360357b132d41ec78bc6e435fd0aa47b8761303","seq":50,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-20","description":"Payment on bill 1","id":10,"lines":[{"account":6,"amount":"80.00","currency":"USD","side":"debit"},{"account":1,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"3495cc1736311c81149fccc5008c8ba80c8770433b65f6c8f365cf1fbbb7c54b","seq":51,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"} +{"payload":{"amount":"80.00","bill_id":1,"cash_account":1,"date":"2026-01-20","entry_id":10},"prev":"1fafcefc65716cad231f193bf9fc50abd11b2a119d9b333483f974956e0fae3d","seq":52,"ts":"2026-10-23T17:49:15Z","type":"bill.paid"} +{"payload":{"date":"2026-01-28","description":"Paid in error","id":11,"lines":[{"account":6,"amount":"80.00","currency":"USD","side":"credit"},{"account":1,"amount":"80.00","currency":"USD","side":"debit"}],"reverses":10},"prev":"e894365a6916297a100c55124ebb5940aea4385b0d20e7adec8665065937bc45","seq":53,"ts":"2026-10-23T17:49:15Z","type":"entry.reversed"} +{"payload":{"date":"2026-02-10","description":"Bill 2","id":12},"prev":"0d6fd6e40ad12a680e5f95664974c7a0ab578f8ecdfecd820a6ee1dbb906381e","seq":54,"ts":"2026-10-23T17:49:15Z","type":"entry.created"} +{"payload":{"account":5,"amount":"40.00","currency":"USD","id":12,"side":"debit"},"prev":"c5e519bd4e997d5582c24365a479be56a5be92929de378496d3583e8fc75a8e2","seq":55,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"account":6,"amount":"40.00","currency":"USD","id":12,"side":"credit"},"prev":"07cfcbe13cea029e232d70c24a26d75ddc72ba10c3131b8a049f0a295031de0d","seq":56,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-10","description":"Bill 2","id":12,"lines":[{"account":5,"amount":"40.00","currency":"USD","side":"debit"},{"account":6,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4c24978be29aed0e01852d5e9f787c53e6c6ba06113da2295417381b448a68cb","seq":57,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"} +{"payload":{"date":"2026-02-10","due_date":"2026-03-12","entry_id":12,"id":2,"lines":[{"amount":"40.00","description":"Ink"}],"vendor_id":1},"prev":"d42003f062e9233ce4282a5efac1b31ea6cb643ea3d4bf8e30e3bda6cfe780a7","seq":58,"ts":"2026-10-23T17:49:15Z","type":"bill.created"} +{"payload":{"date":"2026-02-15","description":"Hand-booked sale","id":13},"prev":"20811aeed7514d05640dac5a339a765500419a9dc46a7abdc9ef90194823f55c","seq":59,"ts":"2026-10-23T17:49:15Z","type":"entry.created"} +{"payload":{"account":3,"amount":"25.00","currency":"USD","id":13,"side":"debit"},"prev":"9359cb76e35884f79412ccc894cd2f11874e1a70a37296da8e58cffe29089836","seq":60,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"account":4,"amount":"25.00","currency":"USD","id":13,"side":"credit"},"prev":"9c667cd07a65b9161d2de4e4bd2fa00f50875255ba6e14d39ecb48ffb3f1296a","seq":61,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-15","description":"Hand-booked sale","id":13,"lines":[{"account":3,"amount":"25.00","currency":"USD","side":"debit"},{"account":4,"amount":"25.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7cb35848235c502669a89c80b9db1ca9942f2c10b9cee4fee5dd3502afde0a77","seq":62,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index fd8ae35..caf6440 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1006,6 +1006,12 @@ mod refusals { Bill(u64, u32, u32, i64), ReceivePayment(u64, u32, i64, u64), PayBill(u64, u32, i64, u64), + /// A payment with its method and reference (plan P4b S1): an + /// invoice's (true) or a bill's, id, day, cents, account, payment + /// method (maybe unknown), reference (none, a good one, two lines). + PayWith(bool, u64, u32, i64, u64, Option, u8), + /// Void an invoice (true) or a bill: id, day, reason (good, blank). + Void(bool, u64, u32, bool), AddClass(u8), SetLineClass(u64, usize, Option), SetEntryVendor(u64, Option), @@ -1101,6 +1107,18 @@ mod refusals { .prop_map(|(i, d, a, c)| Op::ReceivePayment(i, d, a, c)), (0u64..4, 0u32..120, arb_cents(), 0u64..10) .prop_map(|(b, d, a, c)| Op::PayBill(b, d, a, c)), + ( + any::(), + 0u64..4, + 0u32..120, + arb_cents(), + 0u64..10, + prop::option::of(0u64..4), + 0u8..3 + ) + .prop_map(|(k, i, d, a, c, m, r)| Op::PayWith(k, i, d, a, c, m, r)), + (any::(), 0u64..4, 0u32..120, any::()) + .prop_map(|(k, i, d, r)| Op::Void(k, i, d, r)), (0u8..3).prop_map(Op::AddClass), (id.clone(), 0usize..4, prop::option::of(0u32..4)) .prop_map(|(e, i, c)| Op::SetLineClass(e, i, c)), @@ -1286,6 +1304,32 @@ mod refusals { .receive_payment(i, day(d), Decimal::new(a, 2), c) .map(drop), Op::PayBill(b, d, a, c) => store.pay_bill(b, day(d), Decimal::new(a, 2), c).map(drop), + Op::PayWith(invoice, i, d, a, c, m, r) => { + let details = ledger_core::PaymentDetails { + instrument: m, + reference: [None, Some("ch_3Nx 0042"), Some("two\nlines")][r as usize] + .map(str::to_string), + }; + if invoice { + store + .receive_payment_with(i, day(d), Decimal::new(a, 2), c, &details) + .map(drop) + } else { + store + .pay_bill_with(i, day(d), Decimal::new(a, 2), c, &details) + .map(drop) + } + } + Op::Void(invoice, i, d, reason) => { + let doc = if invoice { + ledger_core::DocRef::Invoice(i) + } else { + ledger_core::DocRef::Bill(i) + }; + store + .void_document(doc, day(d), if reason { "duplicate" } else { " " }) + .map(drop) + } Op::AddClass(n) => store.add_class(["A", "B", ""][n as usize]).map(drop), Op::SetLineClass(e, i, c) => store.set_line_class(e, i, c), Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v), @@ -2762,8 +2806,10 @@ fn searching_entries_filters_and_pages() { ); } -/// The database's invoice row follows its payments: amount paid and -/// open / partial / paid, matching the ledger. +/// The database's invoice row keeps no figure of its own (schema 15, +/// rules.md B-38): what it has been paid is the ledger's, read from the +/// lines tagged with it, so a reversed payment no longer counts; the row +/// is the same before and after each payment, and verify agrees. #[test] fn the_database_tracks_what_an_invoice_has_been_paid() { use ledger_core::arap::DocumentLine; @@ -2784,24 +2830,37 @@ fn the_database_tracks_what_an_invoice_has_been_paid() { }], ) .unwrap(); - let row = |dir: &std::path::Path| -> (String, String) { + let row = |dir: &std::path::Path| -> String { let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap(); - conn.query_row( - "SELECT amount_paid, status FROM invoices WHERE id = ?", - [inv as i64], - |r| Ok((r.get(0)?, r.get(1)?)), - ) + conn.query_row("SELECT * FROM invoices WHERE id = ?", [inv as i64], |r| { + Ok(format!( + "{:?}", + (0..6) + .map(|i| r.get_ref(i).map(|v| format!("{v:?}"))) + .collect::>() + )) + }) .unwrap() }; - assert_eq!(row(&dir).1, "open"); - store + let before = row(&dir); + let paid = |s: &Store| { + s.ledger() + .invoice_state(inv, day(31)) + .unwrap() + .unwrap() + .paid + }; + let p = store .receive_payment(inv, day(10), Decimal::from(40), cash) .unwrap(); - assert_eq!(row(&dir), ("40".into(), "partial".into())); + assert_eq!(paid(&store), Decimal::from(40)); + store.reverse_entry(p, day(12), "Bounced").unwrap(); + assert_eq!(paid(&store), Decimal::ZERO); store .receive_payment(inv, day(20), Decimal::from(60), cash) .unwrap(); - assert_eq!(row(&dir), ("100".into(), "paid".into())); + assert_eq!(paid(&store), Decimal::from(60)); + assert_eq!(row(&dir), before, "the row holds no figure"); store.verify().unwrap(); } diff --git a/docs/spec/README.md b/docs/spec/README.md index 8ba6d0d..fc63eee 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -77,4 +77,8 @@ issue costs, cited by B-14 rule 11) is in neither list above; ASC 606-10-25-31, 25-32 and 55-20 (B-16's over-time method) are not yet read; B-15 rule 6 cites ASC 250 with no paragraph; B-17's 606-10-55-39 is in neither list. Each is to be read in the ASC basic view and listed in -`sources/fasb-asc.md` before it is relied on further. +`sources/fasb-asc.md` before it is relied on further. Also open (plan +P4b, 2026-10-23): B-38 cites ASC 310-10, 405-20-40-1 and 606-10-45-4, +none yet read in the ASC basic view; B-38 keeps the CONVENTION label +until they are vendored with a SHA-256, as P4b.16 lists with the sources +B-39 to B-41 will need. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index e1f1835..b45a572 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1035,13 +1035,18 @@ repayment terms." A document due today is not yet past due: DECISION (the owner, 2026-10-13). Aging as of a date shows only documents issued by then, less only payments made by then; and a payment never predates its invoice or bill (a real prepayment is a deposit): DECISIONS (the owner, -2026-10-13). +2026-10-13). What a document owes as of a date is read from its ledger +lines that stand then (B-38): a void invoice is in no bucket, a payment +reversed by then no longer counts, one reversed later still does; the +buckets partition what the documents owe, exactly. -**Enforced:** `arap::aging_bucket`, `Ledger::ar_aging`/`ap_aging`, -`receive_payment`/`pay_bill` (`PaymentBeforeDocument`). **Checked:** +**Enforced:** `arap::aging_bucket`, `Ledger::ar_aging`/`ap_aging` +(through `Ledger::doc_state`), `receive_payment`/`pay_bill` +(`PaymentBeforeDocument`). **Checked:** `aging_boundaries`, `aging_is_exclusive_and_only_ages`, `aging_as_of_a_date_sees_only_what_existed_then`, -`a_payment_never_predates_its_document`. +`a_payment_never_predates_its_document`, `aging_as_of_ignores_a_later_reversal`, +`a_voided_invoice_never_ages`, `status_is_a_function_of_journal_and_date`. ## B-19 1099 reporting thresholds, payment methods, and decisions @@ -1311,13 +1316,26 @@ the chart (DECISION, 2026-10-13). No account fills two roles. A receivables, payables, withholding, or retained earnings account must keep agreeing with what it controls (open invoices, open bills, taxes withheld, closed earnings): such a role moves only when its account holds no -balance, so the bookkeeper first moves the balance with an entry, which -the journal shows. Revenue and expense roles move at any time; earlier -postings stay where they are. +balance. For a withholding or retained earnings role the bookkeeper +first moves the balance with an entry, which the journal shows. Revenue +and expense roles move at any time; earlier postings stay where they +are. Receivables and payables are different (B-38, 2026-10-23): their +documents read their figures from their lines, so their control accounts +take no line by hand and equal their documents by construction. Their +balance moves with its documents only (plan P4b S7); until then a +receivables or payables role whose control account holds a balance stays +where it is, and the refusal says so (it names no hand entry, which the +control guard would refuse). Such a role also never moves onto an +account holding a line that is no document's, and the account it leaves +stays guarded while it holds its documents' lines. The account at a +role's default code is the role's control account only once a document +posts there (or the role is assigned to it): before that, a chart's own +1200 Equipment or a card at 2000 is the chart's. **Enforced:** `Ledger::check_controls`, `Ledger::assign_role` / `check_assign_role`, `Ledger::check_renumber_account` (an assigned role -frees its code). **Checked:** `ledger-core` `tests/spec_roles.rs`; +frees its code); the control guard and `Ledger::control_check` (B-38). +**Checked:** `ledger-core` `tests/spec_roles.rs`, `tests/spec_documents.rs`; `ledger-store` replay of `role.assigned`. ## B-24 Asset tags: one label per asset, never reused @@ -2847,3 +2865,153 @@ and `tests/format_upgrade.rs` (`books_p2_served_cut_over_whole`); bookspec proptests `prop_project_nets_partition_the_pnl`, `prop_project_set_round_trips`, `prop_project_contribution_is_the_net_to_date` (core) and `prop_snapshot_contribution_is_the_net_to_date` (viewer). + +## B-38 A document's figures are its ledger lines that stand; a control account is its documents + +**Status:** DECISION (the owner, 2026-10-23, plan D-45: "we kinda need +stronger invoice support. Like make it a first class citizen"; hand +entries to receivables and payables refused, the owner's answer 5); +CONVENTION (a control account agrees with its subledger, as B-23; the +subledger tie-out is the first thing a CPA checks); sources cited and not +yet vendored, so this rule keeps the CONVENTION label (the Open list in +`README.md`): ASC 310-10 (receivables), ASC 405-20-40-1 (a liability is +removed only when paid or released), ASC 606-10-45-4 (a receivable is a +right to consideration that is unconditional). + +1. **Tags.** Every ledger line on the receivables or payables control + account carries the document it belongs to (an invoice or a bill) and + what it does to it: its origin, a payment (later: a discount, a + write-off, an adoption, a role move). The tag lives beside the line, + like its cleared flag; the line and its hash never change (B-8). The + document commands write it in the step that posts the line; replay + derives it from the document events already journaled (an invoice's + or bill's own entry: its one line on the role's account as the role + stood at that point of the journal; a payment: its first untagged + control line for the payment's amount, so several payment events + naming one entry tag its lines in order), each on the side its + movement posts (an origin on the owed side, a payment on the other, so + an older payment deposited into receivables itself pays with its + credit); a reversal's mirror line carries its original's. +2. **Figures.** What a document owes as of a day is the signed sum of + the lines tagged with it dated on or before that day (a debit to + receivables counts plus for an invoice, a credit to payables for a + bill); what it was paid is minus the same sum over its payment lines. + Its status: Void when its own entry doesn't stand on that day (B-9; + an entry stands while an even number of the reversals after it in its + chain are dated by then, counted in chain order: a reversal counts + only once what it reverses does), else Unattached when its own entry's + control line carries no tag (an older entry replay couldn't match), + else Overpaid when it owes less than nothing (a credit balance, owed + back: never a receivable or payable, ASC 310-10), else Written off, + Paid, Partly paid or Open by what it owes. Overdue is a flag (owed past + the due date), not a status. No figure is stored: a reversal, a void + or a bounced payment is right by construction, and the aging as of a + day (B-18) shows what was owed then; it leaves out a document owing + less than nothing, which the control check lists with the unattached + ones for attention. +3. **The control guard.** A control account is one the receivables or + payables role is assigned to, or one holding a line of one of the + role's documents (the role's default code once a document posted + there; an account the role moved off). A line on one that carries no + document is refused, when added and when posted, whatever writes it + (a hand entry, a template, a recurring entry); a recurring template + naming one is refused when registered. The one exception is the + mirror of an untagged line in a reversal that takes the line off the + books (the hand line itself, or a re-instatement of it, reversed): + reversing an old hand line is how it is cleaned up, and reversing that + cleanup, which would put it back, is refused. The role never moves + onto an account holding an untagged line (B-23). Replay never refuses + (B-28). A chart's own account at a default code (1200 Equipment, a + card at 2000) takes hand lines until a document posts there; lines it + held before are listed as unattached from then on. +4. **Bounds.** At every day, 0 ≤ owed ≤ total, for every document + written under these rules. A payment can't exceed what is owed on any + day from its date on; a reversal that would break the bound where it + held (a void under a payment that stands; re-instating a payment + beside its replacement) is refused, naming what to reverse first; + reversing a payment is always taken. Books an earlier version left out + of bounds take any step that makes no day worse, so the remedy is + accepted (a step may carry a day across the range, overpaid to + over-owed, so long as it lands nearer the range). A reversal of a + document's entry is never dated before that entry. +5. **Payments.** The deposit or payer account is a postable asset or + liability that holds no role: a bank, a card, a member's payable. A + control account (item 3) holds its role; another role is held by its + assigned account, or by the account at its code of its type. + The payment method and the processor's reference go on the payment's + entry in the same step (B-19, D-40). +6. **Voids.** Voiding a document reverses its own entry, described + "Void invoice 7: reason"; it keeps its id (and number), and reversing + the void re-instates it (B-10). +7. **Journal.** A payment accepted only because an earlier payment on + its document was reversed needs the journal feature + `document_standing` (B-27): an older version, which adds every + payment event to the document's paid amount (its line tagged or not), + would show it paid above its total. Everything else is journaled as before, and a void is the + `entry.reversed` it is. + +```text +GOAL: invoices and bills are the subledgers of the receivables and + payables control accounts; every figure about a document is read + from the ledger lines tagged with it. +INVARIANTS: + 1. For every control account A and day D: + GL(A, D) = Σ_docs outstanding(d, D) + unattached(A, D). + No command adds an untagged control line; unattached changes only by + reversing (or, later, adopting) a legacy line. + 2. 0 ≤ outstanding(d, D) ≤ total(d) at every D for every document + written under these rules; replay accepts what was written. + 3. Reversing a tagged entry changes outstanding by exactly the opposite + of its tagged lines, from the reversal's day; reversing that + reversal restores it. + 4. A document's state is a function of (the journal, the day). + 5. Aging as of D partitions Σ outstanding(·, D) over the documents + owing something; a credit balance (only in books an earlier version + left out of bounds) is left out and listed by the control check. + 6. Every refusal leaves the books and the journal unchanged; NEVER panics. +SCENARIOS: + 1. Invoice 100, payment 100, payment reversed: Open, owing 100, A/R 100. + 2. Invoice reversed with nothing paid: Void, not in aging, id kept. + 3. Void with a payment standing: SHOULD refuse, naming the payment. + 4. As of a day between a payment and its reversal: counted as paid. + 5. Wrong payment reversed, right one recorded: accepted (feature + document_standing declared first). + 6. Hand Dr 1200 / Cr 4000: SHOULD refuse with the document tools + named; reversing a legacy hand line on 1200: accepted, unattached + shrinks. + 7. (S7) Half paid, rest written off: Written off, 50 paid / 50 written off. + 8. receive_payment into 1200 or 4000: SHOULD refuse. +``` + +**Enforced:** `ledger_core::arap::{DocRef, DocTag, Movement, DocState, +DocStatus, doc_status, PaymentDetails, ControlCheck}`, +`kernel::{running_bounds, bounds_kept}`, `Ledger::{line_doc, doc_lines, +doc_state, invoice_state, bill_state, stands_on, in_effect, +control_role_of, control_check, payments_recorded, standing_payments, +receive_payment_with, pay_bill_with, void_document, check_void_document, +check_reversal, replay_reverse_entry, ar_aging, ap_aging}` (the guard in +`add_line`, `add_line_fx`, `post_entry` and `register_recurring`); +`Store::{receive_payment_with, pay_bill_with, void_document}` (feature +`document_standing`); `Ledger::check_assign_role` (B-23); the projection +(schema 15) keeps no document figure; the viewer reads what a document +owes and whether it is late from `doc_state` (as of today, or of its own +date when it is dated ahead). +**Checked:** `ledger-core` `tests/spec_documents.rs` (a test per +scenario and finding of its reviews; proptests +`subledger_equals_control`, `aging_partitions_what_documents_owe`, +`reversal_twice_restores_state`); `ledger-store` +`tests/arap_standing.rs` (the fixture an earlier version wrote, its +golden figures adjudicated; the feature gate; replay equals live), +`tests/arap_props.rs` (proptest `status_is_a_function_of_journal_and_date`: +live, replayed and snapshot books agree at every date, closed months +included), the store fuzz test's voids and payments with details; +Kani `bounds_check_matches_definition`, `bounds_kept_is_the_definition_on_books_in_bounds`; +Lean `Documents.control_agrees`, `Documents.Step.document_unattached`, +`Documents.Step.reverse_moves`, `Documents.Reachable.unattached_zero` +(through role assignments and releases), +`Documents.Reachable.control_is_documents`. The model's limits: it +starts from empty books (legacy lines are covered by the identity +`control_agrees` alone), it names documents by one id space (an injective +coding of `DocRef`), and an account becoming a control account by its +first document, holding lines from before, is outside it (those lines are +listed as unattached). diff --git a/docs/testing.md b/docs/testing.md index b575787..e62c007 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2453,3 +2453,114 @@ B-14) are the first. | GnuCash's own close | OPEN | the oracle books closes as ordinary entries; comparing GnuCash's Close Book needs its Scheme API | | Import: spec-blind suite, footing properties, mutation | DONE | sources vendored (Intuit bank CSV and IIF, ECMA-376/Excel numbers, IRS form lines); specs on every module; tests/spec_import.rs (spec-blind, footing and round-trip properties, XLSX read back from its XML, layout and styles); fuzz_import_csv; templates checked against the engine's control accounts; mutation one equivalent left | | MCP, config, GUI crates | DONE (2026-10-14) | the same playbook: sourced module specs (config: Pub 538, Treas. Reg. 301.7701-3; MCP: the MCP tool spec and JSON-RPC 2.0); spec-blind suites `spec_config.rs`, `spec_mcp.rs`, `spec_gui.rs`; the MCP oracle `oracle_mcp.rs` (every export against ledger-core); startup, HTTP concurrency, and the viewer's read-only promise (`read_only.rs`) tested; `fuzz_config_parse`; mutation above | + +## P4b S1, part A: documents read their figures from standing lines (2026-10-23) + +rules.md B-38 (plan P4b, D-45): every line on receivables or payables +carries its document (`Ledger.line_docs`); what a document owes, was paid +and is (Open, Partly paid, Paid, Void) is read from those lines as of a +day; hand lines on the control accounts are refused; a reversal is +bounded so no document is overpaid or owes more than its total; a void is +the reversal of its entry; a payment carries its method and reference; +`Ledger::control_check` ties each control account to its documents. The +journal feature `document_standing` marks a payment accepted only because +an earlier one was reversed. The MCP layer (`books.void_document`, +`books.control_check`, the payment tools' `instrument` and `reference`, +the wiring into verify, the digest and Needs attention) is part B. + +- **Goldens first.** `ledger-store/tests/fixtures/p4-arap-before`: books + written by main at 0df7f52a (before S1) through the store's commands, + with `figures.json`, what that version reported for them (agings at + Jan 22, Jan 31 and Mar 31; each document's balance and status; the + trial balance). `arap_standing.rs` `write_the_p4_arap_fixture` is how + (only a version before S1 can: S1 refuses its hand line). Adjudicated + in `legacy_reversal_now_counts`: the trial balance is the golden's + line for line; Jan 22 unchanged; Jan 31 Fernhill owes 100.00 and Tobin + 80.00 again (their payments' reversals); Mar 31 the voided invoice + leaves the aging and the reopened ones age at 31-60. +- **ledger-core:** `tests/spec_documents.rs`, one test per B-38 scenario + (`reversing_a_payment_reopens_the_invoice`, + `a_voided_invoice_never_ages`, `void_refused_while_paid`, + `aging_as_of_ignores_a_later_reversal`, + `a_corrected_payment_after_reversing_the_wrong_one`, + `hand_line_on_control_refused`, `reversing_a_legacy_hand_line_is_allowed`, + `deposit_account_must_be_cash_like`), and + `reinstating_a_payment_beside_its_replacement_is_refused`, + `reversal_before_original_refused_for_documents`, + `a_payment_carries_its_method_and_reference`, `status_follows_the_figures`, + `a_document_after_the_day_has_no_state`, + `role_reassigned_mid_stream_tags_the_role_then`, + `several_payment_events_on_one_entry_tag_its_lines_in_order`; from the + reviews of part A, `standing_payments_never_names_a_reversal`, + `a_payment_into_the_control_account_tags_its_credit`, + `a_void_never_reads_as_paid_in_the_record`, + `moving_a_document_role_keeps_the_guard`, + `a_chart_whose_1200_is_not_receivables`, + `out_of_bounds_documents_are_listed_not_aged`, + `putting_back_a_removed_hand_line_is_refused`, + `a_void_of_a_closed_periods_invoice_lands_in_the_open_period`, + `payments_recorded_counts_every_event`, `a_chain_counts_in_order`; + proptests `subledger_equals_control` (random creates, payments, voids, + reversals of any entry and refused hand lines: at every day no + difference, nothing unattached, 0 <= owed <= total, every refusal + changes nothing), `aging_partitions_what_documents_owe`, + `reversal_twice_restores_state` (the move is read from the entry + reversed, not its mirror, so a lost tag copy is caught; reversing a + standing payment must be accepted, so the property is never vacuous + there). `spec_roles.rs` models assign_role SCENARIO 6 (a document role + never moves onto hand lines) and the narrowed balance rule. Tests + whose fixtures post by hand to 1200 or 2000 (report, cash flow, 1099, + role and K-1 fixtures, the bookspec examples marked `older = true`) + post those lines as replay does (`parties::replaying`): books an + earlier version wrote hold such lines, and every report must still + read them; where the code was incidental (a card at 2000, equipment at + 1200) the account was renumbered instead. +- **ledger-store:** `tests/arap_standing.rs` + (`legacy_reversal_now_counts`, + `repaying_after_a_reversal_waits_for_document_standing`: refused, + journaling nothing, until the upgrade; replay equals live after; + `the_store_refuses_hand_lines_and_a_void_under_a_payment`, + `a_void_is_journaled_as_its_reversal`, + `a_payment_journals_its_method_and_reference` (a plain payment's + payload field for field as before), `legacy_documents_replay_with_tags`); + `tests/arap_props.rs` `status_is_a_function_of_journal_and_date` (B-38 + INVARIANT 4: random invoices, bills, payments with and without a + method and reference, voids, reversals of any entry, closed months and + refused hand lines through `Store`; at half time and at the end the + books are reopened and read by a `Snapshot`, and `differs_from`, every + document's state, the control check and both agings agree at every + date); the store fuzz test (`tests/store.rs`, refusals journal nothing) + gains `void_document` and the payments with details; + `format.rs` `document_standing_is_its_own_feature`. Schema 15 drops the + projection's `amount_paid` and `status` (they summed payment events, a + reversed one included); `verify` compares the rest. +- **Kani:** `bounds_check_matches_definition` (`kernel::running_bounds` + is the definition), `bounds_kept_is_the_definition_on_books_in_bounds` + (the command's check is the definition on books in bounds, and never + lets a day get worse on books out of bounds). +- **Lean:** `Wanderbooks/Documents.lean`: `control_agrees` (GL = Σ docs + + unattached, for any lines, and through any day), `Step.document_unattached`, + `Step.reverse_moves`, `reverse_twice`, `Reachable.unattached_zero`, + `Reachable.control_is_documents`. The steps include assigning a + document role (only onto an account whose lines are all documents', + `check_assign_role` SCENARIO 6) and releasing an account no document + posted to. What the model leaves out, so the proofs aren't read as + covering it: it starts from empty books, so legacy hand lines (and the + reversal-mirror exemption that cleans them up) are covered only by the + identity `control_agrees`; documents are one `Nat` id space (an + injective coding of `DocRef`); an account that becomes a control + account by its first document (the default code), holding lines from + before, is outside it (`control_check` lists those lines). + +**For P12 (mutation targets):** `stands_on`'s count and its in-order +`take_while`; +`doc_state`'s movement arms and `overdue_days`; `doc_status`'s order; +`check_hand_line`/`check_control_lines` (the reversal-mirror exemption); +`control_role_of`'s two clauses and the exemption's even-depth test; +`check_assign_role`'s SCENARIO 6 and its `controls` filter; +`standing_payments`' `!reversal`; `holds_role`'s type check; +`bounds_kept`'s `>`; `payment_room`'s `from`; `tag_replayed`'s side, +amount match and one-line rule; `refresh_doc`'s settled sum; +`doc_status`'s Overpaid and Unattached arms; the aging's `<= 0`; +`ensure_payment_features`' comparison; `check_document_reversal`'s date +check; `control_check`'s chain netting. diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index f801482..c015231 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -89,3 +89,10 @@ open Wanderbooks #print axioms Prorate.chargeLast_mono #print axioms Prorate.kernel_parts_total #print axioms Prorate.kernel_parts_nonneg +#print axioms Documents.control_agrees +#print axioms Documents.control_agrees_through +#print axioms Documents.Step.document_unattached +#print axioms Documents.Step.reverse_moves +#print axioms Documents.reverse_twice +#print axioms Documents.Reachable.unattached_zero +#print axioms Documents.Reachable.control_is_documents diff --git a/verify/lean/Wanderbooks.lean b/verify/lean/Wanderbooks.lean index 15931d5..aef9ec9 100644 --- a/verify/lean/Wanderbooks.lean +++ b/verify/lean/Wanderbooks.lean @@ -6,3 +6,4 @@ import Wanderbooks.Ledger import Wanderbooks.Apportion import Wanderbooks.Parties import Wanderbooks.Prorate +import Wanderbooks.Documents diff --git a/verify/lean/Wanderbooks/Documents.lean b/verify/lean/Wanderbooks/Documents.lean new file mode 100644 index 0000000..4575fc3 --- /dev/null +++ b/verify/lean/Wanderbooks/Documents.lean @@ -0,0 +1,280 @@ +import Wanderbooks.Entry + +/-! +# Documents are the subledgers of their control accounts (rules.md B-38) + +Every ledger line may carry the document it belongs to (an invoice or a +bill: `Ledger.line_docs`, `arap::DocTag`). A document's figures are the +sum of its tagged lines; a control account's balance is the sum of every +line on it. Mirrors `Ledger::doc_state` (outstanding), `control_check`, +the control guard (`check_control_lines`: every line a command posts on a +control account carries its document) and `reverse_entry` (a mirror line +copies its original's tag). + +The headline, `control_agrees`: for every account and any lines at all, +the account's balance is the sum over the documents of what each carries +on it, plus its untagged ("unattached") lines. Both sides sum the same +lines, so it is an identity, not a reconciliation, and it holds through +any date, since a date is a filter on the lines. A document is one `Nat` +here: an injective coding of `DocRef` (invoices and bills in one id +space), which is all the sums need. Then, over the commands: +a document step (an invoice, a bill, a payment, or any entry whose +control lines are tagged) never moves what is unattached; a reversal +moves each document, and what is unattached, by exactly the opposite of +the lines it mirrors (B-38 INVARIANT 3); so books built by these steps +alone have nothing unattached on a control account, ever +(`Reachable.unattached_zero`). +-/ + +namespace Wanderbooks.Documents + +open Wanderbooks + +/-- A posted line, its day, and the document it carries (`none`: none). -/ +structure TLine where + line : Line + date : Nat + doc : Option Nat + deriving DecidableEq + +/-- A line's signed amount on account `a` (zero on another account). -/ +def onAcct (a : Nat) (l : TLine) : Int := if l.line.account = a then l.line.signed else 0 + +/-- Account `a`'s balance over some lines (the general ledger). -/ +def gl (a : Nat) : List TLine → Int + | [] => 0 + | l :: ls => onAcct a l + gl a ls + +/-- What document `d`'s lines on account `a` sum to (`doc_lines`). -/ +def docSum (a d : Nat) : List TLine → Int + | [] => 0 + | l :: ls => (if l.doc = some d then onAcct a l else 0) + docSum a d ls + +/-- Account `a`'s lines that carry no document. -/ +def unattached (a : Nat) : List TLine → Int + | [] => 0 + | l :: ls => (if l.doc = none then onAcct a l else 0) + unattached a ls + +@[simp] theorem gl_nil (a : Nat) : gl a [] = 0 := rfl +@[simp] theorem docSum_nil (a d : Nat) : docSum a d [] = 0 := rfl +@[simp] theorem unattached_nil (a : Nat) : unattached a [] = 0 := rfl + +theorem gl_append (a : Nat) (xs ys : List TLine) : gl a (xs ++ ys) = gl a xs + gl a ys := by + induction xs with + | nil => simp + | cons x xs ih => simp only [List.cons_append, gl, ih]; omega + +theorem docSum_append (a d : Nat) (xs ys : List TLine) : + docSum a d (xs ++ ys) = docSum a d xs + docSum a d ys := by + induction xs with + | nil => simp + | cons x xs ih => simp only [List.cons_append, docSum, ih]; omega + +theorem unattached_append (a : Nat) (xs ys : List TLine) : + unattached a (xs ++ ys) = unattached a xs + unattached a ys := by + induction xs with + | nil => simp + | cons x xs ih => simp only [List.cons_append, unattached, ih]; omega + +/-- Every document some lines carry is among `ds`. -/ +def Covers (ds : List Nat) (ls : List TLine) : Prop := + ∀ l ∈ ls, ∀ k, l.doc = some k → k ∈ ds + +/-- **The control account agrees with its documents** (B-38 INVARIANT 1): +for any lines, any account, and any distinct document ids covering the +lines' tags, the balance is the documents' sum plus what is unattached. -/ +theorem control_agrees (a : Nat) (ds : List Nat) (hd : Distinct ds) : + ∀ ls : List TLine, Covers ds ls → + gl a ls = sumOver (fun d => docSum a d ls) ds + unattached a ls := by + intro ls + induction ls with + | nil => intro _; simp [sumOver_zero] + | cons l ls ih => + intro hc + have hc' : Covers ds ls := fun m hm => hc m (List.mem_cons_of_mem _ hm) + simp only [gl, docSum, unattached] + rw [sumOver_add, ih hc'] + cases h : l.doc with + | none => + simp [sumOver_zero] <;> omega + | some k => + have hk : k ∈ ds := hc l (List.mem_cons_self ..) k h + have e : sumOver (fun d => if some k = some d then onAcct a l else 0) ds + = sumOver (fun d => if k = d then onAcct a l else 0) ds := + sumOver_congr _ _ ds (fun d _ => by simp) + simp only [e, sumOver_indicator k (onAcct a l) ds hd hk] + simp <;> omega + +/-- The lines dated on or before day `D`. -/ +def through (D : Nat) (ls : List TLine) : List TLine := ls.filter (fun l => decide (l.date ≤ D)) + +theorem Covers.through {ds : List Nat} {ls : List TLine} (h : Covers ds ls) (D : Nat) : + Covers ds (through D ls) := fun l hl => h l (List.mem_filter.1 hl).1 + +/-- **The same through any day**: GL(A, D) = Σ_docs outstanding(d, D) + +unattached(A, D). -/ +theorem control_agrees_through (a : Nat) (ds : List Nat) (hd : Distinct ds) (ls : List TLine) + (hc : Covers ds ls) (D : Nat) : + gl a (through D ls) = sumOver (fun d => docSum a d (through D ls)) ds + + unattached a (through D ls) := + control_agrees a ds hd _ (hc.through D) + +/-! ## The commands -/ + +/-- A line mirrored by a reversal: its side flipped, its day the +reversal's, its document kept (`reverse_entry_steps` copies the tag). -/ +def mirror (D : Nat) (l : TLine) : TLine := { l with line := l.line.reverse, date := D } + +theorem onAcct_mirror (a D : Nat) (l : TLine) : onAcct a (mirror D l) = -onAcct a l := by + unfold onAcct mirror + by_cases h : l.line.account = a + · simp [Line.reverse, h] + have := Line.reverse_signed l.line + simp [Line.reverse] at this + exact this + · simp [Line.reverse, h] + +theorem docSum_mirror (a d D : Nat) (ls : List TLine) : + docSum a d (ls.map (mirror D)) = -docSum a d ls := by + induction ls with + | nil => simp + | cons l ls ih => + simp only [List.map_cons, docSum, ih, onAcct_mirror] + have : (mirror D l).doc = l.doc := rfl + rw [this] + by_cases h : l.doc = some d <;> simp [h] <;> omega + +theorem unattached_mirror (a D : Nat) (ls : List TLine) : + unattached a (ls.map (mirror D)) = -unattached a ls := by + induction ls with + | nil => simp + | cons l ls ih => + simp only [List.map_cons, unattached, ih, onAcct_mirror] + have : (mirror D l).doc = l.doc := rfl + rw [this] + by_cases h : l.doc = none <;> simp [h] <;> omega + +/-- Every line of `ls` on a control account carries a document: what the +control guard requires of every entry a command posts. -/ +def Tagged (control : List Nat) (ls : List TLine) : Prop := + ∀ l ∈ ls, l.line.account ∈ control → l.doc ≠ none + +/-- The books: the control accounts (the receivables and payables roles) +and every posted line, in order. -/ +structure Books where + control : List Nat + lines : List TLine + +/-- What the engine allows (S1): a document command, or any entry whose +control lines are all tagged (`Step.document`); a reversal of posted +lines, mirror for mirror, each keeping its tag (`Step.reverse`), dated on +or after them (B-38, "Dating rules"); assigning a document role an +account all of whose lines are documents' (`Step.assign`: +`check_assign_role` SCENARIO 6 refuses one holding an untagged line); and +an account leaving the control accounts once it holds no document's line +(`Step.release`: an assigned role moved off an account no document posted +to; an account holding a document's line stays guarded, +`control_role_of`). -/ +inductive Step : Books → Books → Prop + | document (B : Books) (e : List TLine) (h : Tagged B.control e) : + Step B { B with lines := B.lines ++ e } + | reverse (B : Books) (e : List TLine) (he : ∀ l ∈ e, l ∈ B.lines) (D : Nat) : + Step B { B with lines := B.lines ++ e.map (mirror D) } + | assign (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc ≠ none) : + Step B { B with control := c :: B.control } + | release (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc = none) : + Step B { B with control := B.control.erase c } + +/-- **A document step never moves what is unattached** on a control +account: no command adds an untagged control line. -/ +theorem Step.document_unattached (B : Books) (e : List TLine) (h : Tagged B.control e) + (a : Nat) (ha : a ∈ B.control) : unattached a (B.lines ++ e) = unattached a B.lines := by + rw [unattached_append] + suffices unattached a e = 0 by omega + induction e with + | nil => rfl + | cons l ls ih => + simp only [unattached] + rw [ih (fun m hm => h m (List.mem_cons_of_mem _ hm))] + by_cases hn : l.doc = none + · have hacc : l.line.account ≠ a := fun e => h l (List.mem_cons_self ..) (e ▸ ha) hn + simp [hn, onAcct, hacc] + · simp [hn] + +/-- **A reversal moves each document by exactly the opposite of its lines +it mirrors** (B-38 INVARIANT 3), and what is unattached likewise: only +reversing a legacy hand line changes it. -/ +theorem Step.reverse_moves (B : Books) (e : List TLine) (D a d : Nat) : + docSum a d (B.lines ++ e.map (mirror D)) = docSum a d B.lines - docSum a d e + ∧ unattached a (B.lines ++ e.map (mirror D)) = unattached a B.lines - unattached a e := by + rw [docSum_append, docSum_mirror, unattached_append, unattached_mirror] + omega + +/-- **Reversing a reversal restores every document** (B-10): the lines, +mirrored twice, sum as the originals did. -/ +theorem reverse_twice (a d D D' : Nat) (e : List TLine) : + docSum a d ((e.map (mirror D)).map (mirror D')) = docSum a d e := by + rw [docSum_mirror, docSum_mirror]; omega + +/-- States reachable from books with no lines. -/ +inductive Reachable : Books → Prop + | empty (control : List Nat) : Reachable ⟨control, []⟩ + | step {B B' : Books} : Reachable B → Step B B' → Reachable B' + +theorem Reachable.tagged {B : Books} (r : Reachable B) : Tagged B.control B.lines := by + induction r with + | empty => intro l hl; simp at hl + | step _ s ih => + cases s with + | document e h => + intro l hl hc + rcases List.mem_append.1 hl with hl | hl + · exact ih l hl hc + · exact h l hl hc + | reverse e he D => + intro l hl hc + rcases List.mem_append.1 hl with hl | hl + · exact ih l hl hc + · obtain ⟨m, hm, rfl⟩ := List.mem_map.1 hl + exact ih m (he m hm) (by simpa [mirror, Line.reverse] using hc) + | assign c h => + intro l hl hc + rcases List.mem_cons.1 hc with hc | hc + · exact h l hl hc + · exact ih l hl hc + | release c h => + intro l hl hc + exact ih l hl (List.mem_of_mem_erase hc) + +/-- **Nothing is ever unattached on a control account** in books these +steps build, through any day, roles assigned and released included: +every control line is a document's. (Books an earlier version wrote, with +hand lines, are covered by the identity `control_agrees` alone; an +account becoming a control account by its first document, holding lines +from before, is outside the model: `control_check` lists those lines.) -/ +theorem Reachable.unattached_zero {B : Books} (r : Reachable B) (a : Nat) (ha : a ∈ B.control) + (D : Nat) : unattached a (through D B.lines) = 0 := by + have ht := r.tagged + have : ∀ ls : List TLine, (∀ l ∈ ls, l ∈ B.lines) → unattached a ls = 0 := by + intro ls + induction ls with + | nil => intro _; rfl + | cons l ls ih => + intro hs + simp only [unattached] + rw [ih (fun m hm => hs m (List.mem_cons_of_mem _ hm))] + by_cases hn : l.doc = none + · have hacc : l.line.account ≠ a := + fun e => ht l (hs l (List.mem_cons_self ..)) (e ▸ ha) hn + simp [hn, onAcct, hacc] + · simp [hn] + exact this _ (fun l hl => (List.mem_filter.1 hl).1) + +/-- **So the control account is its documents**, exactly, on every day: +GL(A, D) = Σ_docs outstanding(d, D). -/ +theorem Reachable.control_is_documents {B : Books} (r : Reachable B) (a : Nat) + (ha : a ∈ B.control) (ds : List Nat) (hd : Distinct ds) (hc : Covers ds B.lines) (D : Nat) : + gl a (through D B.lines) = sumOver (fun d => docSum a d (through D B.lines)) ds := by + rw [control_agrees_through a ds hd B.lines hc D, r.unattached_zero a ha D]; omega + +end Wanderbooks.Documents